fix(data): 增加全局保存失败保护
This commit is contained in:
81
apps/web/components/ServerDataSaveErrorBanner.tsx
Normal file
81
apps/web/components/ServerDataSaveErrorBanner.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SERVER_DATA_SAVE_ERROR_EVENT,
|
||||||
|
type ServerDataKey,
|
||||||
|
type ServerDataSaveErrorDetail,
|
||||||
|
} from '@/lib/server-data';
|
||||||
|
|
||||||
|
const KEY_LABELS: Record<ServerDataKey, string> = {
|
||||||
|
'products-overview': '产品/项目/版本',
|
||||||
|
requirements: '需求池',
|
||||||
|
'version-plans': '版本计划',
|
||||||
|
'dev-tasks': '开发任务',
|
||||||
|
'test-cases': '测试用例',
|
||||||
|
bugs: 'BUG',
|
||||||
|
members: '成员/账号',
|
||||||
|
'task-categories': '任务类型',
|
||||||
|
'task-worklogs': '工时日志',
|
||||||
|
'work-activities': '工作动态',
|
||||||
|
'xiaobao-risk-insights': '小宝预警解读',
|
||||||
|
'xiaobao-risk-snapshots': '小宝预警快照',
|
||||||
|
'xiaobao-warning-views': '小宝预警查看状态',
|
||||||
|
overtime: '加班记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServerDataSaveErrorBanner() {
|
||||||
|
const [detail, setDetail] = useState<ServerDataSaveErrorDetail | null>(null);
|
||||||
|
const dismissTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function clearDismissTimer() {
|
||||||
|
if (!dismissTimer.current) return;
|
||||||
|
window.clearTimeout(dismissTimer.current);
|
||||||
|
dismissTimer.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveError(event: Event) {
|
||||||
|
const next = (event as CustomEvent<ServerDataSaveErrorDetail>).detail;
|
||||||
|
if (!next?.key) return;
|
||||||
|
clearDismissTimer();
|
||||||
|
setDetail(next);
|
||||||
|
dismissTimer.current = window.setTimeout(() => setDetail(null), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(SERVER_DATA_SAVE_ERROR_EVENT, handleSaveError);
|
||||||
|
return () => {
|
||||||
|
clearDismissTimer();
|
||||||
|
window.removeEventListener(SERVER_DATA_SAVE_ERROR_EVENT, handleSaveError);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!detail) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="fixed right-4 top-4 z-[100] flex w-[min(420px,calc(100vw-32px))] items-start gap-3 rounded-lg border border-red-200 bg-white px-4 py-3 text-left shadow-lg"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-semibold text-red-700">保存失败</p>
|
||||||
|
<p className="mt-0.5 text-[12px] leading-5 text-red-600">
|
||||||
|
{KEY_LABELS[detail.key]}没有写入服务器。请检查外网 API/网络后重试,必要时刷新页面。
|
||||||
|
</p>
|
||||||
|
{detail.message && (
|
||||||
|
<p className="mt-1 truncate text-[11px] text-red-500">{detail.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetail(null)}
|
||||||
|
className="rounded-md p-1 text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
aria-label="关闭保存失败提示"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { AuthGuard } from '@/components/AuthGuard';
|
import { AuthGuard } from '@/components/AuthGuard';
|
||||||
|
import { ServerDataSaveErrorBanner } from '@/components/ServerDataSaveErrorBanner';
|
||||||
|
|
||||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -10,6 +11,7 @@ export function LayoutShell({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
|
<ServerDataSaveErrorBanner />
|
||||||
{isLoginPage ? (
|
{isLoginPage ? (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
53
apps/web/lib/optimistic-persistence.test.ts
Normal file
53
apps/web/lib/optimistic-persistence.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { saveWithOptimisticRollback } from './optimistic-persistence';
|
||||||
|
|
||||||
|
test('rolls back optimistic state when the save fails and state is unchanged', async () => {
|
||||||
|
const previous = [{ id: 'old' }];
|
||||||
|
const optimistic = [{ id: 'new' }];
|
||||||
|
let current = optimistic;
|
||||||
|
let rolledBack = false;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveWithOptimisticRollback({
|
||||||
|
save: async () => {
|
||||||
|
throw new Error('save failed');
|
||||||
|
},
|
||||||
|
expected: optimistic,
|
||||||
|
getCurrent: () => current,
|
||||||
|
rollback: () => {
|
||||||
|
rolledBack = true;
|
||||||
|
current = previous;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/save failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(rolledBack, true);
|
||||||
|
assert.equal(current, previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not roll back a newer optimistic state when an older save fails', async () => {
|
||||||
|
const optimistic = [{ id: 'new' }];
|
||||||
|
const newer = [{ id: 'newer' }];
|
||||||
|
let current = newer;
|
||||||
|
let rolledBack = false;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveWithOptimisticRollback({
|
||||||
|
save: async () => {
|
||||||
|
throw new Error('save failed');
|
||||||
|
},
|
||||||
|
expected: optimistic,
|
||||||
|
getCurrent: () => current,
|
||||||
|
rollback: () => {
|
||||||
|
rolledBack = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/save failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(rolledBack, false);
|
||||||
|
assert.equal(current, newer);
|
||||||
|
});
|
||||||
28
apps/web/lib/optimistic-persistence.ts
Normal file
28
apps/web/lib/optimistic-persistence.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
type OptimisticRollbackOptions<T> = {
|
||||||
|
save: () => Promise<void>;
|
||||||
|
expected: T;
|
||||||
|
getCurrent: () => T;
|
||||||
|
rollback: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveWithOptimisticRollback<T>({
|
||||||
|
save,
|
||||||
|
expected,
|
||||||
|
getCurrent,
|
||||||
|
rollback,
|
||||||
|
}: OptimisticRollbackOptions<T>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await save();
|
||||||
|
} catch (error) {
|
||||||
|
if (Object.is(getCurrent(), expected)) {
|
||||||
|
rollback();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleSaveWithOptimisticRollback<T>(
|
||||||
|
options: OptimisticRollbackOptions<T>,
|
||||||
|
): void {
|
||||||
|
void saveWithOptimisticRollback(options).catch(() => undefined);
|
||||||
|
}
|
||||||
18
apps/web/lib/server-data-save-error-source.test.ts
Normal file
18
apps/web/lib/server-data-save-error-source.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('server data save failures are surfaced through a global browser event', () => {
|
||||||
|
const source = readFileSync(join(process.cwd(), 'lib/server-data.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';/);
|
||||||
|
assert.match(source, /window\.dispatchEvent\(\s*new CustomEvent\(SERVER_DATA_SAVE_ERROR_EVENT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('layout shell renders the global server data save error banner', () => {
|
||||||
|
const source = readFileSync(join(process.cwd(), 'components/layout/LayoutShell.tsx'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /import \{ ServerDataSaveErrorBanner \} from '@\/components\/ServerDataSaveErrorBanner';/);
|
||||||
|
assert.match(source, /<ServerDataSaveErrorBanner \/>/);
|
||||||
|
});
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { loadServerData, saveServerData, ServerDataConflictError } from './server-data';
|
import { __resetApiAvailabilityForTests } from './api';
|
||||||
|
import {
|
||||||
|
loadServerData,
|
||||||
|
saveServerData,
|
||||||
|
ServerDataConflictError,
|
||||||
|
SERVER_DATA_SAVE_ERROR_EVENT,
|
||||||
|
type ServerDataSaveErrorDetail,
|
||||||
|
} from './server-data';
|
||||||
|
|
||||||
type MockResponse = {
|
type MockResponse = {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -172,3 +179,55 @@ test('server data reuses a fresh cached load for repeated AppData reads', async
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('server data save failures dispatch a browser-visible failure event', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
const events: Array<CustomEvent<ServerDataSaveErrorDetail>> = [];
|
||||||
|
__resetApiAvailabilityForTests();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: (event: Event) => {
|
||||||
|
events.push(event as CustomEvent<ServerDataSaveErrorDetail>);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith('/config/ai')) {
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith('/data/requirements') && init?.method === 'PUT') {
|
||||||
|
return new Response(JSON.stringify({ message: 'offline' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => saveServerData('requirements', []), /offline/);
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.equal(events[0].type, SERVER_DATA_SAVE_ERROR_EVENT);
|
||||||
|
assert.deepEqual(events[0].detail, {
|
||||||
|
key: 'requirements',
|
||||||
|
message: 'offline',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
__resetApiAvailabilityForTests();
|
||||||
|
if (originalWindowDescriptor) {
|
||||||
|
Object.defineProperty(globalThis, 'window', originalWindowDescriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(globalThis, 'window');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ const serverDataCache = new Map<ServerDataKey, {
|
|||||||
}>();
|
}>();
|
||||||
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
|
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
|
||||||
export const SERVER_DATA_CACHE_MS = 30_000;
|
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 = {
|
type LoadServerDataOptions = {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
@@ -54,6 +60,24 @@ export class ServerDataConflictError<T = unknown> extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>(
|
export async function loadServerData<T>(
|
||||||
key: ServerDataKey,
|
key: ServerDataKey,
|
||||||
options: LoadServerDataOptions = {},
|
options: LoadServerDataOptions = {},
|
||||||
@@ -107,12 +131,15 @@ export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<v
|
|||||||
error.status === 409 &&
|
error.status === 409 &&
|
||||||
isServerDataConflictBody<T>(error.body, key)
|
isServerDataConflictBody<T>(error.body, key)
|
||||||
) {
|
) {
|
||||||
throw new ServerDataConflictError(
|
const conflict = new ServerDataConflictError(
|
||||||
error.body.key,
|
error.body.key,
|
||||||
error.body.currentValue,
|
error.body.currentValue,
|
||||||
error.body.currentVersion,
|
error.body.currentVersion,
|
||||||
);
|
);
|
||||||
|
notifyServerDataSaveFailure(key, conflict);
|
||||||
|
throw conflict;
|
||||||
}
|
}
|
||||||
|
notifyServerDataSaveFailure(key, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
||||||
import { generateBugNo } from '@/lib/bug';
|
import { generateBugNo } from '@/lib/bug';
|
||||||
import { applyBugTransition } from '@/lib/bug-workflow';
|
import { applyBugTransition } from '@/lib/bug-workflow';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import {
|
import {
|
||||||
makeBugCreatedActivity,
|
makeBugCreatedActivity,
|
||||||
@@ -11,10 +12,6 @@ import {
|
|||||||
} from '@/lib/work-activity-factory';
|
} from '@/lib/work-activity-factory';
|
||||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||||
|
|
||||||
function saveStored(items: Bug[]) {
|
|
||||||
saveServerData('bugs', items).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastBugsFetchAt = 0;
|
let lastBugsFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<Bug[] | null> {
|
async function loadStored(): Promise<Bug[] | null> {
|
||||||
@@ -69,23 +66,40 @@ export const useBugStore = create<BugState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
const updated = [...list, bug];
|
const updated = [...list, bug];
|
||||||
set({ bugs: updated, loaded: true });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('bugs', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().bugs,
|
||||||
|
rollback: () => set({ bugs: list, loaded: true }),
|
||||||
|
});
|
||||||
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
||||||
return bug;
|
return bug;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateBug: (id, data) => {
|
updateBug: (id, data) => {
|
||||||
const updated = get().bugs.map((b) =>
|
const previous = get().bugs;
|
||||||
|
const updated = previous.map((b) =>
|
||||||
b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b,
|
b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b,
|
||||||
);
|
);
|
||||||
set({ bugs: updated, loaded: true });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('bugs', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().bugs,
|
||||||
|
rollback: () => set({ bugs: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBug: (id) => {
|
deleteBug: (id) => {
|
||||||
const updated = get().bugs.filter((b) => b.id !== id);
|
const previous = get().bugs;
|
||||||
|
const updated = previous.filter((b) => b.id !== id);
|
||||||
set({ bugs: updated, loaded: true });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('bugs', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().bugs,
|
||||||
|
rollback: () => set({ bugs: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
changeStatus: (id, to, operator, extra) => {
|
changeStatus: (id, to, operator, extra) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
|||||||
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import {
|
import {
|
||||||
makeDevTaskBlockedActivity,
|
makeDevTaskBlockedActivity,
|
||||||
@@ -73,23 +74,40 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
} as DevTask);
|
} as DevTask);
|
||||||
const updated = [...list, task];
|
const updated = [...list, task];
|
||||||
set({ tasks: updated, loaded: true });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('dev-tasks', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().tasks,
|
||||||
|
rollback: () => set({ tasks: list, loaded: true }),
|
||||||
|
});
|
||||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||||
return task;
|
return task;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTask: (id, data) => {
|
updateTask: (id, data) => {
|
||||||
const updated = get().tasks.map((t) =>
|
const previous = get().tasks;
|
||||||
|
const updated = previous.map((t) =>
|
||||||
t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t,
|
t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t,
|
||||||
);
|
);
|
||||||
set({ tasks: updated, loaded: true });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('dev-tasks', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().tasks,
|
||||||
|
rollback: () => set({ tasks: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTask: (id) => {
|
deleteTask: (id) => {
|
||||||
const updated = get().tasks.filter((t) => t.id !== id);
|
const previous = get().tasks;
|
||||||
|
const updated = previous.filter((t) => t.id !== id);
|
||||||
set({ tasks: updated, loaded: true });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('dev-tasks', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().tasks,
|
||||||
|
rollback: () => set({ tasks: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
changeStatus: (id, to, opts) => {
|
changeStatus: (id, to, opts) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ensureSystemAdminMember,
|
ensureSystemAdminMember,
|
||||||
sanitizeSystemAdminPatch,
|
sanitizeSystemAdminPatch,
|
||||||
} from '@/lib/member-system';
|
} from '@/lib/member-system';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
|
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
|
||||||
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
|
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
@@ -108,38 +109,68 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updatePasswordRule: (rule) => {
|
updatePasswordRule: (rule) => {
|
||||||
|
const previous = get().passwordRule;
|
||||||
set({ passwordRule: rule });
|
set({ passwordRule: rule });
|
||||||
void saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }),
|
||||||
|
expected: rule,
|
||||||
|
getCurrent: () => get().passwordRule,
|
||||||
|
rollback: () => set({ passwordRule: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createDepartment: (data) => {
|
createDepartment: (data) => {
|
||||||
|
const previous = get().departments;
|
||||||
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const departments = [...get().departments, dept];
|
const departments = [...previous, dept];
|
||||||
set({ departments });
|
set({ departments });
|
||||||
void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: departments,
|
||||||
|
getCurrent: () => get().departments,
|
||||||
|
rollback: () => set({ departments: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
updateDepartment: (id, data) => {
|
updateDepartment: (id, data) => {
|
||||||
const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d);
|
const previous = get().departments;
|
||||||
|
const departments = previous.map((d) => d.id === id ? { ...d, ...data } : d);
|
||||||
set({ departments });
|
set({ departments });
|
||||||
void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: departments,
|
||||||
|
getCurrent: () => get().departments,
|
||||||
|
rollback: () => set({ departments: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deleteDepartment: (id) => {
|
deleteDepartment: (id) => {
|
||||||
const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id);
|
const previous = get().departments;
|
||||||
|
const departments = previous.filter((d) => d.id !== id && d.parentId !== id);
|
||||||
set({ departments });
|
set({ departments });
|
||||||
void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: departments,
|
||||||
|
getCurrent: () => get().departments,
|
||||||
|
rollback: () => set({ departments: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createMember: (data) => {
|
createMember: (data) => {
|
||||||
|
const previous = get().members;
|
||||||
const member: Member = {
|
const member: Member = {
|
||||||
...data,
|
...data,
|
||||||
username: data.username || generateUsernameFromName(data.name, get().members.map((m) => m.username ?? '')),
|
username: data.username || generateUsernameFromName(data.name, previous.map((m) => m.username ?? '')),
|
||||||
phone: data.phone ?? '',
|
phone: data.phone ?? '',
|
||||||
id: `m-${Date.now()}`,
|
id: `m-${Date.now()}`,
|
||||||
createdAt: new Date().toISOString().slice(0, 10),
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
};
|
};
|
||||||
const members = [...get().members, member];
|
const members = [...previous, member];
|
||||||
set({ members });
|
set({ members });
|
||||||
void saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: members,
|
||||||
|
getCurrent: () => get().members,
|
||||||
|
rollback: () => set({ members: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
updateMember: async (id, data) => {
|
updateMember: async (id, data) => {
|
||||||
const previousMembers = get().members;
|
const previousMembers = get().members;
|
||||||
@@ -156,24 +187,37 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
deleteMember: (id) => {
|
deleteMember: (id) => {
|
||||||
if (!canDeleteSystemMember(id)) return;
|
if (!canDeleteSystemMember(id)) return;
|
||||||
const members = get().members.filter((m) => m.id !== id);
|
const previous = get().members;
|
||||||
|
const members = previous.filter((m) => m.id !== id);
|
||||||
set({ members });
|
set({ members });
|
||||||
void saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: members,
|
||||||
|
getCurrent: () => get().members,
|
||||||
|
rollback: () => set({ members: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createRole: (data) => {
|
createRole: (data) => {
|
||||||
|
const previous = get().roles;
|
||||||
const role: RoleItem = {
|
const role: RoleItem = {
|
||||||
...data,
|
...data,
|
||||||
id: `role-${Date.now()}`,
|
id: `role-${Date.now()}`,
|
||||||
createdAt: new Date().toISOString().slice(0, 10),
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
permissions: data.permissions ?? [],
|
permissions: data.permissions ?? [],
|
||||||
};
|
};
|
||||||
const roles = [...get().roles, role];
|
const roles = [...previous, role];
|
||||||
set({ roles });
|
set({ roles });
|
||||||
void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: roles,
|
||||||
|
getCurrent: () => get().roles,
|
||||||
|
rollback: () => set({ roles: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
updateRole: (id, data) => {
|
updateRole: (id, data) => {
|
||||||
const roles = get().roles.map((r) => {
|
const previous = get().roles;
|
||||||
|
const roles = previous.map((r) => {
|
||||||
if (r.id !== id) return r;
|
if (r.id !== id) return r;
|
||||||
// 超管不允许改 permissions(防误操作锁死系统)
|
// 超管不允许改 permissions(防误操作锁死系统)
|
||||||
if (r.id === 'role-admin' && data.permissions) {
|
if (r.id === 'role-admin' && data.permissions) {
|
||||||
@@ -183,12 +227,23 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
return { ...r, ...data };
|
return { ...r, ...data };
|
||||||
});
|
});
|
||||||
set({ roles });
|
set({ roles });
|
||||||
void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: roles,
|
||||||
|
getCurrent: () => get().roles,
|
||||||
|
rollback: () => set({ roles: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deleteRole: (id) => {
|
deleteRole: (id) => {
|
||||||
if (id === SYSTEM_ADMIN_ROLE_ID) return;
|
if (id === SYSTEM_ADMIN_ROLE_ID) return;
|
||||||
const roles = get().roles.filter((r) => r.id !== id);
|
const previous = get().roles;
|
||||||
|
const roles = previous.filter((r) => r.id !== id);
|
||||||
set({ roles });
|
set({ roles });
|
||||||
void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {});
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||||
|
expected: roles,
|
||||||
|
getCurrent: () => get().roles,
|
||||||
|
rollback: () => set({ roles: previous }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { OvertimeRecord } from '@/lib/overtime';
|
import type { OvertimeRecord } from '@/lib/overtime';
|
||||||
import type { DictItem } from '@/lib/requirement';
|
import type { DictItem } from '@/lib/requirement';
|
||||||
import { calcDuration } from '@/lib/overtime';
|
import { calcDuration } from '@/lib/overtime';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
|
|
||||||
const PRESET_REASONS: DictItem[] = [
|
const PRESET_REASONS: DictItem[] = [
|
||||||
@@ -36,10 +37,6 @@ interface OvertimeState {
|
|||||||
deleteReason: (id: string) => void;
|
deleteReason: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
|
|
||||||
saveServerData('overtime', data).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
|
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
|
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
|
||||||
@@ -62,6 +59,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
createRecord: (data) => {
|
createRecord: (data) => {
|
||||||
|
const previous = get().records;
|
||||||
const duration = calcDuration(data.startTime, data.endTime);
|
const duration = calcDuration(data.startTime, data.endTime);
|
||||||
const record: OvertimeRecord = {
|
const record: OvertimeRecord = {
|
||||||
...data,
|
...data,
|
||||||
@@ -69,13 +67,19 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
duration,
|
duration,
|
||||||
createdAt: new Date().toISOString().slice(0, 10),
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
};
|
};
|
||||||
const records = [...get().records, record];
|
const records = [...previous, record];
|
||||||
set({ records, loaded: true });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||||
|
expected: records,
|
||||||
|
getCurrent: () => get().records,
|
||||||
|
rollback: () => set({ records: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRecord: (id, data) => {
|
updateRecord: (id, data) => {
|
||||||
const records = get().records.map((r) => {
|
const previous = get().records;
|
||||||
|
const records = previous.map((r) => {
|
||||||
if (r.id !== id) return r;
|
if (r.id !== id) return r;
|
||||||
const updated = { ...r, ...data };
|
const updated = { ...r, ...data };
|
||||||
if (data.startTime || data.endTime) {
|
if (data.startTime || data.endTime) {
|
||||||
@@ -84,31 +88,60 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
set({ records, loaded: true });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||||
|
expected: records,
|
||||||
|
getCurrent: () => get().records,
|
||||||
|
rollback: () => set({ records: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteRecord: (id) => {
|
deleteRecord: (id) => {
|
||||||
const records = get().records.filter((r) => r.id !== id);
|
const previous = get().records;
|
||||||
|
const records = previous.filter((r) => r.id !== id);
|
||||||
set({ records, loaded: true });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||||
|
expected: records,
|
||||||
|
getCurrent: () => get().records,
|
||||||
|
rollback: () => set({ records: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addReason: (name) => {
|
addReason: (name) => {
|
||||||
|
const previous = get().reasons;
|
||||||
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const reasons = [...get().reasons, item];
|
const reasons = [...previous, item];
|
||||||
set({ reasons, loaded: true });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||||
|
expected: reasons,
|
||||||
|
getCurrent: () => get().reasons,
|
||||||
|
rollback: () => set({ reasons: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateReason: (id, name) => {
|
updateReason: (id, name) => {
|
||||||
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
|
const previous = get().reasons;
|
||||||
|
const reasons = previous.map((r) => r.id === id ? { ...r, name } : r);
|
||||||
set({ reasons, loaded: true });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||||
|
expected: reasons,
|
||||||
|
getCurrent: () => get().reasons,
|
||||||
|
rollback: () => set({ reasons: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteReason: (id) => {
|
deleteReason: (id) => {
|
||||||
const reasons = get().reasons.filter((r) => r.id !== id);
|
const previous = get().reasons;
|
||||||
|
const reasons = previous.filter((r) => r.id !== id);
|
||||||
set({ reasons, loaded: true });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||||
|
expected: reasons,
|
||||||
|
getCurrent: () => get().reasons,
|
||||||
|
rollback: () => set({ reasons: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { Product } from '@ftb/shared';
|
import { Product } from '@ftb/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
|
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import type { Stage, Role } from '@/lib/stage';
|
import type { Stage, Role } from '@/lib/stage';
|
||||||
@@ -139,6 +140,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
createProduct: async (data) => {
|
createProduct: async (data) => {
|
||||||
|
const previousOverview = get().overview;
|
||||||
const newProduct: ProductOverview = {
|
const newProduct: ProductOverview = {
|
||||||
id: `local-${Date.now()}`,
|
id: `local-${Date.now()}`,
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
@@ -149,46 +151,81 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
versions: [],
|
versions: [],
|
||||||
_count: { requirements: 0, projects: 0, versions: 0 },
|
_count: { requirements: 0, projects: 0, versions: 0 },
|
||||||
};
|
};
|
||||||
const updated = [newProduct, ...get().overview];
|
const updated = [newProduct, ...previousOverview];
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProduct: async (id, data) => {
|
updateProduct: async (id, data) => {
|
||||||
const updated = get().overview.map((p) =>
|
const previousOverview = get().overview;
|
||||||
|
const previousCurrentProduct = get().currentProduct;
|
||||||
|
const updated = previousOverview.map((p) =>
|
||||||
p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p,
|
p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p,
|
||||||
);
|
);
|
||||||
const currentProduct = get().currentProduct;
|
|
||||||
set({
|
set({
|
||||||
overview: updated,
|
overview: updated,
|
||||||
overviewLoaded: true,
|
overviewLoaded: true,
|
||||||
currentProduct:
|
currentProduct:
|
||||||
currentProduct?.id === id ? ({ ...currentProduct, ...data } as any) : currentProduct,
|
previousCurrentProduct?.id === id ? ({ ...previousCurrentProduct, ...data } as any) : previousCurrentProduct,
|
||||||
|
});
|
||||||
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({
|
||||||
|
overview: previousOverview,
|
||||||
|
overviewLoaded: true,
|
||||||
|
currentProduct: previousCurrentProduct,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
await saveStoredOverview(updated);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteProduct: async (id) => {
|
deleteProduct: async (id) => {
|
||||||
const updated = get().overview.filter((p) => p.id !== id);
|
const previousOverview = get().overview;
|
||||||
|
const previousProducts = get().products;
|
||||||
|
const previousCurrentProduct = get().currentProduct;
|
||||||
|
const updated = previousOverview.filter((p) => p.id !== id);
|
||||||
set({
|
set({
|
||||||
products: get().products.filter((p) => p.id !== id),
|
products: previousProducts.filter((p) => p.id !== id),
|
||||||
overview: updated,
|
overview: updated,
|
||||||
overviewLoaded: true,
|
overviewLoaded: true,
|
||||||
currentProduct: get().currentProduct?.id === id ? null : get().currentProduct,
|
currentProduct: previousCurrentProduct?.id === id ? null : previousCurrentProduct,
|
||||||
|
});
|
||||||
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({
|
||||||
|
products: previousProducts,
|
||||||
|
overview: previousOverview,
|
||||||
|
overviewLoaded: true,
|
||||||
|
currentProduct: previousCurrentProduct,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
await saveStoredOverview(updated);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderProducts: async (ids) => {
|
reorderProducts: async (ids) => {
|
||||||
const map = new Map(get().overview.map((p) => [p.id, p]));
|
const previousOverview = get().overview;
|
||||||
|
const map = new Map(previousOverview.map((p) => [p.id, p]));
|
||||||
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
||||||
set({ overview: reordered, overviewLoaded: true });
|
set({ overview: reordered, overviewLoaded: true });
|
||||||
await saveStoredOverview(reordered);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(reordered),
|
||||||
|
expected: reordered,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
migrateAndDeleteProduct: async (sourceId, targetId) => {
|
migrateAndDeleteProduct: async (sourceId, targetId) => {
|
||||||
const source = get().overview.find((p) => p.id === sourceId);
|
const previousOverview = get().overview;
|
||||||
const target = get().overview.find((p) => p.id === targetId);
|
const source = previousOverview.find((p) => p.id === sourceId);
|
||||||
|
const target = previousOverview.find((p) => p.id === targetId);
|
||||||
if (!source || !target) return;
|
if (!source || !target) return;
|
||||||
|
|
||||||
const migratedTarget: ProductOverview = {
|
const migratedTarget: ProductOverview = {
|
||||||
@@ -202,21 +239,27 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const updated = get().overview
|
const updated = previousOverview
|
||||||
.filter((p) => p.id !== sourceId)
|
.filter((p) => p.id !== sourceId)
|
||||||
.map((p) => (p.id === targetId ? migratedTarget : p));
|
.map((p) => (p.id === targetId ? migratedTarget : p));
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createProject: async (productId, data) => {
|
createProject: async (productId, data) => {
|
||||||
|
const previousOverview = get().overview;
|
||||||
const newProject = {
|
const newProject = {
|
||||||
id: `proj-${Date.now()}`,
|
id: `proj-${Date.now()}`,
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const updated = get().overview.map((p) => {
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -225,11 +268,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProject: async (productId, projectId, data) => {
|
updateProject: async (productId, projectId, data) => {
|
||||||
const updated = get().overview.map((p) => {
|
const previousOverview = get().overview;
|
||||||
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
const currentProject = p.projects.find((proj) => proj.id === projectId);
|
const currentProject = p.projects.find((proj) => proj.id === projectId);
|
||||||
if (!currentProject) return p;
|
if (!currentProject) return p;
|
||||||
@@ -262,10 +311,16 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
createVersion: async (productId, data) => {
|
createVersion: async (productId, data) => {
|
||||||
|
const previousOverview = get().overview;
|
||||||
const newVersion: VersionItem = {
|
const newVersion: VersionItem = {
|
||||||
id: `ver-${Date.now()}`,
|
id: `ver-${Date.now()}`,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -274,7 +329,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
...(data as any),
|
...(data as any),
|
||||||
};
|
};
|
||||||
const updated = get().overview.map((p) => {
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -283,11 +338,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateVersion: async (productId, versionId, data) => {
|
updateVersion: async (productId, versionId, data) => {
|
||||||
const updated = get().overview.map((p) => {
|
const previousOverview = get().overview;
|
||||||
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -297,11 +358,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteVersion: async (productId, versionId) => {
|
deleteVersion: async (productId, versionId) => {
|
||||||
const updated = get().overview.map((p) => {
|
const previousOverview = get().overview;
|
||||||
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -310,11 +377,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteProject: async (productId, projectId) => {
|
deleteProject: async (productId, projectId) => {
|
||||||
const updated = get().overview.map((p) => {
|
const previousOverview = get().overview;
|
||||||
|
const updated = previousOverview.map((p) => {
|
||||||
if (p.id !== productId) return p;
|
if (p.id !== productId) return p;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -323,7 +396,12 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated, overviewLoaded: true });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
await saveStoredOverview(updated);
|
await saveWithOptimisticRollback({
|
||||||
|
save: () => saveStoredOverview(updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().overview,
|
||||||
|
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
|
|
||||||
interface RequirementState {
|
interface RequirementState {
|
||||||
@@ -55,12 +56,6 @@ const PRESET_PLATFORMS: DictItem[] = [
|
|||||||
const MOCK_REQUIREMENTS: Requirement[] = [];
|
const MOCK_REQUIREMENTS: Requirement[] = [];
|
||||||
let lastRequirementsFetchAt = 0;
|
let lastRequirementsFetchAt = 0;
|
||||||
|
|
||||||
// --- Server persistence helpers ---
|
|
||||||
|
|
||||||
function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
|
|
||||||
saveServerData('requirements', state).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> {
|
async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
|
return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
|
||||||
@@ -103,21 +98,36 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
};
|
};
|
||||||
const updated = [...requirements, newReq];
|
const updated = [...requirements, newReq];
|
||||||
set({ requirements: updated, loaded: true });
|
set({ requirements: updated, loaded: true });
|
||||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().requirements,
|
||||||
|
rollback: () => set({ requirements, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRequirement: (id, data) => {
|
updateRequirement: (id, data) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
|
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
|
||||||
set({ requirements: updated, loaded: true });
|
set({ requirements: updated, loaded: true });
|
||||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().requirements,
|
||||||
|
rollback: () => set({ requirements, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteRequirement: (id) => {
|
deleteRequirement: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = requirements.filter((r) => r.id !== id);
|
const updated = requirements.filter((r) => r.id !== id);
|
||||||
set({ requirements: updated, loaded: true });
|
set({ requirements: updated, loaded: true });
|
||||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().requirements,
|
||||||
|
rollback: () => set({ requirements, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- SourceTarget dict ---
|
// --- SourceTarget dict ---
|
||||||
@@ -126,21 +136,36 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...sourceTargets, item];
|
const updated = [...sourceTargets, item];
|
||||||
set({ sourceTargets: updated, loaded: true });
|
set({ sourceTargets: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().sourceTargets,
|
||||||
|
rollback: () => set({ sourceTargets, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSourceTarget: (id, name) => {
|
updateSourceTarget: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
|
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
|
||||||
set({ sourceTargets: updated, loaded: true });
|
set({ sourceTargets: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().sourceTargets,
|
||||||
|
rollback: () => set({ sourceTargets, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSourceTarget: (id) => {
|
deleteSourceTarget: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = sourceTargets.filter((s) => s.id !== id);
|
const updated = sourceTargets.filter((s) => s.id !== id);
|
||||||
set({ sourceTargets: updated, loaded: true });
|
set({ sourceTargets: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().sourceTargets,
|
||||||
|
rollback: () => set({ sourceTargets, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Type dict ---
|
// --- Type dict ---
|
||||||
@@ -149,21 +174,36 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...types, item];
|
const updated = [...types, item];
|
||||||
set({ types: updated, loaded: true });
|
set({ types: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().types,
|
||||||
|
rollback: () => set({ types, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateType: (id, name) => {
|
updateType: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
|
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
|
||||||
set({ types: updated, loaded: true });
|
set({ types: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().types,
|
||||||
|
rollback: () => set({ types, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteType: (id) => {
|
deleteType: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = types.filter((t) => t.id !== id);
|
const updated = types.filter((t) => t.id !== id);
|
||||||
set({ types: updated, loaded: true });
|
set({ types: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().types,
|
||||||
|
rollback: () => set({ types, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Platform dict ---
|
// --- Platform dict ---
|
||||||
@@ -172,21 +212,36 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...platforms, item];
|
const updated = [...platforms, item];
|
||||||
set({ platforms: updated, loaded: true });
|
set({ platforms: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().platforms,
|
||||||
|
rollback: () => set({ platforms, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlatform: (id, name) => {
|
updatePlatform: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
|
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
|
||||||
set({ platforms: updated, loaded: true });
|
set({ platforms: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().platforms,
|
||||||
|
rollback: () => set({ platforms, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePlatform: (id) => {
|
deletePlatform: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = platforms.filter((p) => p.id !== id);
|
const updated = platforms.filter((p) => p.id !== id);
|
||||||
set({ platforms: updated, loaded: true });
|
set({ platforms: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().platforms,
|
||||||
|
rollback: () => set({ platforms, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
||||||
import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category';
|
import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
|
|
||||||
function saveStored(items: TaskCategory[]) {
|
|
||||||
saveServerData('task-categories', items).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastCategoriesFetchAt = 0;
|
let lastCategoriesFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<TaskCategory[] | null> {
|
async function loadStored(): Promise<TaskCategory[] | null> {
|
||||||
@@ -52,30 +49,53 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
const updated = [...list, item];
|
const updated = [...list, item];
|
||||||
set({ categories: updated, loaded: true });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-categories', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().categories,
|
||||||
|
rollback: () => set({ categories: list, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
ensureCategory: (name, group, options) => {
|
ensureCategory: (name, group, options) => {
|
||||||
|
const previous = get().categories;
|
||||||
const result = resolveAiTaskCategoryByName(get().categories, name, group, options);
|
const result = resolveAiTaskCategoryByName(get().categories, name, group, options);
|
||||||
if (result.created) {
|
if (result.created) {
|
||||||
set({ categories: result.categories, loaded: true });
|
set({ categories: result.categories, loaded: true });
|
||||||
saveStored(result.categories);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-categories', result.categories),
|
||||||
|
expected: result.categories,
|
||||||
|
getCurrent: () => get().categories,
|
||||||
|
rollback: () => set({ categories: previous, loaded: true }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result.category;
|
return result.category;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateCategory: (id, data) => {
|
updateCategory: (id, data) => {
|
||||||
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
|
const previous = get().categories;
|
||||||
|
const updated = previous.map((c) => (c.id === id ? { ...c, ...data } : c));
|
||||||
set({ categories: updated, loaded: true });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-categories', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().categories,
|
||||||
|
rollback: () => set({ categories: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteCategory: (id) => {
|
deleteCategory: (id) => {
|
||||||
const target = get().categories.find((c) => c.id === id);
|
const target = get().categories.find((c) => c.id === id);
|
||||||
if (!target || target.isSystem) return false;
|
if (!target || target.isSystem) return false;
|
||||||
const updated = get().categories.filter((c) => c.id !== id);
|
const previous = get().categories;
|
||||||
|
const updated = previous.filter((c) => c.id !== id);
|
||||||
set({ categories: updated, loaded: true });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-categories', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().categories,
|
||||||
|
rollback: () => set({ categories: previous, loaded: true }),
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TaskWorklog } from '@/lib/task-worklog';
|
import type { TaskWorklog } from '@/lib/task-worklog';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
|
|
||||||
function saveStored(items: TaskWorklog[]) {
|
|
||||||
saveServerData('task-worklogs', items).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastWorklogsFetchAt = 0;
|
let lastWorklogsFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<TaskWorklog[] | null> {
|
async function loadStored(): Promise<TaskWorklog[] | null> {
|
||||||
@@ -38,20 +35,32 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addWorklog: (data) => {
|
addWorklog: (data) => {
|
||||||
|
const previous = get().worklogs;
|
||||||
const item: TaskWorklog = {
|
const item: TaskWorklog = {
|
||||||
...data,
|
...data,
|
||||||
id: `wl-${Date.now()}`,
|
id: `wl-${Date.now()}`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const updated = [...get().worklogs, item];
|
const updated = [...previous, item];
|
||||||
set({ worklogs: updated, loaded: true });
|
set({ worklogs: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-worklogs', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().worklogs,
|
||||||
|
rollback: () => set({ worklogs: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteWorklog: (id) => {
|
deleteWorklog: (id) => {
|
||||||
const updated = get().worklogs.filter((w) => w.id !== id);
|
const previous = get().worklogs;
|
||||||
|
const updated = previous.filter((w) => w.id !== id);
|
||||||
set({ worklogs: updated, loaded: true });
|
set({ worklogs: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('task-worklogs', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().worklogs,
|
||||||
|
rollback: () => set({ worklogs: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getActualHours: (taskId) => {
|
getActualHours: (taskId) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-c
|
|||||||
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
||||||
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
||||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import {
|
import {
|
||||||
makeTestCaseCreatedActivity,
|
makeTestCaseCreatedActivity,
|
||||||
@@ -69,7 +70,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
} as TestCase);
|
} as TestCase);
|
||||||
const updated = [...list, tc];
|
const updated = [...list, tc];
|
||||||
set({ testCases: updated, loaded: true });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('test-cases', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().testCases,
|
||||||
|
rollback: () => set({ testCases: list, loaded: true }),
|
||||||
|
});
|
||||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||||
return tc;
|
return tc;
|
||||||
},
|
},
|
||||||
@@ -77,6 +83,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
createTestCases: (items) => {
|
createTestCases: (items) => {
|
||||||
if (items.length === 0) return [];
|
if (items.length === 0) return [];
|
||||||
let list = get().testCases;
|
let list = get().testCases;
|
||||||
|
const previous = list;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const created: TestCase[] = [];
|
const created: TestCase[] = [];
|
||||||
for (const data of items) {
|
for (const data of items) {
|
||||||
@@ -92,7 +99,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
list = [...list, tc];
|
list = [...list, tc];
|
||||||
}
|
}
|
||||||
set({ testCases: list, loaded: true });
|
set({ testCases: list, loaded: true });
|
||||||
saveStored(list);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('test-cases', list),
|
||||||
|
expected: list,
|
||||||
|
getCurrent: () => get().testCases,
|
||||||
|
rollback: () => set({ testCases: previous, loaded: true }),
|
||||||
|
});
|
||||||
created.forEach((tc) => {
|
created.forEach((tc) => {
|
||||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||||
});
|
});
|
||||||
@@ -100,17 +112,29 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTestCase: (id, data) => {
|
updateTestCase: (id, data) => {
|
||||||
const updated = get().testCases.map((c) =>
|
const previous = get().testCases;
|
||||||
|
const updated = previous.map((c) =>
|
||||||
c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,
|
c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,
|
||||||
);
|
);
|
||||||
set({ testCases: updated, loaded: true });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('test-cases', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().testCases,
|
||||||
|
rollback: () => set({ testCases: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTestCase: (id) => {
|
deleteTestCase: (id) => {
|
||||||
const updated = get().testCases.filter((c) => c.id !== id);
|
const previous = get().testCases;
|
||||||
|
const updated = previous.filter((c) => c.id !== id);
|
||||||
set({ testCases: updated, loaded: true });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('test-cases', updated),
|
||||||
|
expected: updated,
|
||||||
|
getCurrent: () => get().testCases,
|
||||||
|
rollback: () => set({ testCases: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
changeStatus: (id, to, extra) => {
|
changeStatus: (id, to, extra) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||||
|
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||||
@@ -17,10 +18,6 @@ import { useWorkActivityStore } from './useWorkActivityStore';
|
|||||||
const MOCK_PLANS: VersionPlan[] = [];
|
const MOCK_PLANS: VersionPlan[] = [];
|
||||||
let lastPlansFetchAt = 0;
|
let lastPlansFetchAt = 0;
|
||||||
|
|
||||||
function saveStored(plans: VersionPlan[]) {
|
|
||||||
saveServerData('version-plans', plans).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStored(): Promise<VersionPlan[] | null> {
|
async function loadStored(): Promise<VersionPlan[] | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<VersionPlan[]>('version-plans');
|
return await loadServerData<VersionPlan[]>('version-plans');
|
||||||
@@ -51,17 +48,24 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
createPlan: (data) => {
|
createPlan: (data) => {
|
||||||
|
const previous = get().plans;
|
||||||
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const plans = [...get().plans, plan];
|
const plans = [...previous, plan];
|
||||||
set({ plans, loaded: true });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('version-plans', plans),
|
||||||
|
expected: plans,
|
||||||
|
getCurrent: () => get().plans,
|
||||||
|
rollback: () => set({ plans: previous, loaded: true }),
|
||||||
|
});
|
||||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlan: (id, data) => {
|
updatePlan: (id, data) => {
|
||||||
|
const previous = get().plans;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const activities: WorkActivityDraft[] = [];
|
const activities: WorkActivityDraft[] = [];
|
||||||
const plans = get().plans.map((p) => {
|
const plans = previous.map((p) => {
|
||||||
if (p.id !== id) return p;
|
if (p.id !== id) return p;
|
||||||
const patch = { ...data };
|
const patch = { ...data };
|
||||||
if (patch.status === 'in_progress' && !p.actualStartAt) {
|
if (patch.status === 'in_progress' && !p.actualStartAt) {
|
||||||
@@ -88,13 +92,19 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
set({ plans, loaded: true });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('version-plans', plans),
|
||||||
|
expected: plans,
|
||||||
|
getCurrent: () => get().plans,
|
||||||
|
rollback: () => set({ plans: previous, loaded: true }),
|
||||||
|
});
|
||||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
||||||
},
|
},
|
||||||
|
|
||||||
completePlan: (id, result) => {
|
completePlan: (id, result) => {
|
||||||
let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' };
|
let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' };
|
||||||
const plans = get().plans.map((p) => {
|
const previous = get().plans;
|
||||||
|
const plans = previous.map((p) => {
|
||||||
if (p.id !== id) return p;
|
if (p.id !== id) return p;
|
||||||
const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
|
const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
|
||||||
const state = getPlanCompletionState(next);
|
const state = getPlanCompletionState(next);
|
||||||
@@ -107,13 +117,24 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
set({ plans, loaded: true });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('version-plans', plans),
|
||||||
|
expected: plans,
|
||||||
|
getCurrent: () => get().plans,
|
||||||
|
rollback: () => set({ plans: previous, loaded: true }),
|
||||||
|
});
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePlan: (id) => {
|
deletePlan: (id) => {
|
||||||
const plans = get().plans.filter((p) => p.id !== id);
|
const previous = get().plans;
|
||||||
|
const plans = previous.filter((p) => p.id !== id);
|
||||||
set({ plans, loaded: true });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
scheduleSaveWithOptimisticRollback({
|
||||||
|
save: () => saveServerData('version-plans', plans),
|
||||||
|
expected: plans,
|
||||||
|
getCurrent: () => get().plans,
|
||||||
|
rollback: () => set({ plans: previous, loaded: true }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user