176 lines
5.0 KiB
TypeScript
176 lines
5.0 KiB
TypeScript
import { api, ApiRequestError } from './api';
|
|
|
|
export type ServerDataKey =
|
|
| 'products-overview'
|
|
| 'requirements'
|
|
| 'version-plans'
|
|
| 'dev-tasks'
|
|
| 'test-cases'
|
|
| 'bugs'
|
|
| 'members'
|
|
| 'task-categories'
|
|
| 'task-worklogs'
|
|
| 'work-activities'
|
|
| 'xiaobao-risk-insights'
|
|
| 'xiaobao-risk-snapshots'
|
|
| 'xiaobao-warning-views'
|
|
| 'overtime';
|
|
|
|
interface ServerDataResponse<T> {
|
|
key: ServerDataKey;
|
|
value: T | null;
|
|
version: string | null;
|
|
}
|
|
|
|
type ServerDataConflictBody<T> = {
|
|
code: 'APP_DATA_CONFLICT';
|
|
key: ServerDataKey;
|
|
currentValue: T | null;
|
|
currentVersion: string | null;
|
|
};
|
|
|
|
const serverDataVersions = new Map<ServerDataKey, string | null>();
|
|
const serverDataCache = new Map<ServerDataKey, {
|
|
value: unknown | null;
|
|
version: string | null;
|
|
loadedAt: number;
|
|
}>();
|
|
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
|
|
const serverDataSaveQueues = new Map<ServerDataKey, Promise<void>>();
|
|
export const SERVER_DATA_CACHE_MS = 30_000;
|
|
export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';
|
|
|
|
export type ServerDataSaveErrorDetail = {
|
|
key: ServerDataKey;
|
|
message: string;
|
|
};
|
|
|
|
type LoadServerDataOptions = {
|
|
force?: boolean;
|
|
maxAgeMs?: number;
|
|
};
|
|
|
|
export class ServerDataConflictError<T = unknown> extends Error {
|
|
constructor(
|
|
public readonly key: ServerDataKey,
|
|
public readonly currentValue: T | null,
|
|
public readonly currentVersion: string | null,
|
|
) {
|
|
super('Server data was changed by another user. Reload before saving again.');
|
|
this.name = 'ServerDataConflictError';
|
|
}
|
|
}
|
|
|
|
function getServerDataSaveErrorMessage(error: unknown) {
|
|
if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试';
|
|
if (error instanceof Error && error.message) return error.message;
|
|
return '保存失败,请检查网络或 API 服务';
|
|
}
|
|
|
|
function notifyServerDataSaveFailure(key: ServerDataKey, error: unknown) {
|
|
if (typeof window === 'undefined') return;
|
|
window.dispatchEvent(
|
|
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
|
|
detail: {
|
|
key,
|
|
message: getServerDataSaveErrorMessage(error),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function loadServerData<T>(
|
|
key: ServerDataKey,
|
|
options: LoadServerDataOptions = {},
|
|
): Promise<T | null> {
|
|
const maxAgeMs = options.maxAgeMs ?? SERVER_DATA_CACHE_MS;
|
|
const cached = serverDataCache.get(key);
|
|
if (!options.force && cached && maxAgeMs > 0 && Date.now() - cached.loadedAt <= maxAgeMs) {
|
|
return cached.value as T | null;
|
|
}
|
|
|
|
const inFlight = serverDataLoadPromises.get(key);
|
|
if (inFlight) return inFlight as Promise<T | null>;
|
|
|
|
const promise = api.get<ServerDataResponse<T>>(`/data/${key}`)
|
|
.then((res) => {
|
|
const version = res.version ?? null;
|
|
serverDataVersions.set(key, version);
|
|
serverDataCache.set(key, {
|
|
value: res.value,
|
|
version,
|
|
loadedAt: Date.now(),
|
|
});
|
|
return res.value;
|
|
})
|
|
.finally(() => {
|
|
serverDataLoadPromises.delete(key);
|
|
});
|
|
|
|
serverDataLoadPromises.set(key, promise as Promise<unknown | null>);
|
|
return promise;
|
|
}
|
|
|
|
export function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
|
const previousSave = serverDataSaveQueues.get(key) ?? Promise.resolve();
|
|
const save = previousSave.then(() => putServerData(key, value));
|
|
const queueTail = save.catch(() => undefined);
|
|
serverDataSaveQueues.set(key, queueTail);
|
|
void queueTail.then(() => {
|
|
if (serverDataSaveQueues.get(key) === queueTail) {
|
|
serverDataSaveQueues.delete(key);
|
|
}
|
|
});
|
|
return save;
|
|
}
|
|
|
|
async function putServerData<T>(key: ServerDataKey, value: T): Promise<void> {
|
|
const payload: { value: T; version?: string | null } = { value };
|
|
if (serverDataVersions.has(key)) {
|
|
payload.version = serverDataVersions.get(key) ?? null;
|
|
}
|
|
|
|
try {
|
|
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
|
|
const version = res.version ?? null;
|
|
serverDataVersions.set(key, version);
|
|
serverDataCache.set(key, {
|
|
value,
|
|
version,
|
|
loadedAt: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof ApiRequestError &&
|
|
error.status === 409 &&
|
|
isServerDataConflictBody<T>(error.body, key)
|
|
) {
|
|
const conflict = new ServerDataConflictError(
|
|
error.body.key,
|
|
error.body.currentValue,
|
|
error.body.currentVersion,
|
|
);
|
|
notifyServerDataSaveFailure(key, conflict);
|
|
throw conflict;
|
|
}
|
|
notifyServerDataSaveFailure(key, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isServerDataConflictBody<T>(
|
|
body: unknown,
|
|
key: ServerDataKey,
|
|
): body is ServerDataConflictBody<T> {
|
|
return (
|
|
typeof body === 'object' &&
|
|
body !== null &&
|
|
(body as { code?: unknown }).code === 'APP_DATA_CONFLICT' &&
|
|
(body as { key?: unknown }).key === key &&
|
|
('currentVersion' in body
|
|
? typeof (body as { currentVersion?: unknown }).currentVersion === 'string' ||
|
|
(body as { currentVersion?: unknown }).currentVersion === null
|
|
: true)
|
|
);
|
|
}
|