fix(data): 增加全局保存失败保护

This commit is contained in:
Script Generator
2026-07-03 19:36:18 +08:00
parent 516a5f1b63
commit b4a3c990b4
17 changed files with 727 additions and 132 deletions

View File

@@ -3,6 +3,7 @@ import { create } from 'zustand';
import type { Bug, BugStatus, BugLog } from '@/lib/bug';
import { generateBugNo } from '@/lib/bug';
import { applyBugTransition } from '@/lib/bug-workflow';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import {
makeBugCreatedActivity,
@@ -11,10 +12,6 @@ import {
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: Bug[]) {
saveServerData('bugs', items).catch(() => {});
}
let lastBugsFetchAt = 0;
async function loadStored(): Promise<Bug[] | null> {
@@ -69,23 +66,40 @@ export const useBugStore = create<BugState>((set, get) => ({
};
const updated = [...list, bug];
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));
return bug;
},
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,
);
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) => {
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('bugs', updated),
expected: updated,
getCurrent: () => get().bugs,
rollback: () => set({ bugs: previous, loaded: true }),
});
},
changeStatus: (id, to, operator, extra) => {

View File

@@ -4,6 +4,7 @@ import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
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 {
makeDevTaskBlockedActivity,
@@ -73,23 +74,40 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
} as DevTask);
const updated = [...list, task];
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));
return task;
},
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,
);
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) => {
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('dev-tasks', updated),
expected: updated,
getCurrent: () => get().tasks,
rollback: () => set({ tasks: previous, loaded: true }),
});
},
changeStatus: (id, to, opts) => {

View File

@@ -9,6 +9,7 @@ import {
ensureSystemAdminMember,
sanitizeSystemAdminPatch,
} from '@/lib/member-system';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
@@ -108,38 +109,68 @@ export const useMemberStore = create<MemberState>((set, get) => ({
},
updatePasswordRule: (rule) => {
const previous = get().passwordRule;
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) => {
const previous = get().departments;
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 });
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) => {
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 });
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) => {
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 });
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) => {
const previous = get().members;
const member: Member = {
...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 ?? '',
id: `m-${Date.now()}`,
createdAt: new Date().toISOString().slice(0, 10),
};
const members = [...get().members, member];
const members = [...previous, member];
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) => {
const previousMembers = get().members;
@@ -156,24 +187,37 @@ export const useMemberStore = create<MemberState>((set, get) => ({
},
deleteMember: (id) => {
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 });
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) => {
const previous = get().roles;
const role: RoleItem = {
...data,
id: `role-${Date.now()}`,
createdAt: new Date().toISOString().slice(0, 10),
permissions: data.permissions ?? [],
};
const roles = [...get().roles, role];
const roles = [...previous, role];
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) => {
const roles = get().roles.map((r) => {
const previous = get().roles;
const roles = previous.map((r) => {
if (r.id !== id) return r;
// 超管不允许改 permissions防误操作锁死系统
if (r.id === 'role-admin' && data.permissions) {
@@ -183,12 +227,23 @@ export const useMemberStore = create<MemberState>((set, get) => ({
return { ...r, ...data };
});
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) => {
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 });
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 }),
});
},
}));

View File

@@ -3,6 +3,7 @@ import { create } from 'zustand';
import type { OvertimeRecord } from '@/lib/overtime';
import type { DictItem } from '@/lib/requirement';
import { calcDuration } from '@/lib/overtime';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
const PRESET_REASONS: DictItem[] = [
@@ -36,10 +37,6 @@ interface OvertimeState {
deleteReason: (id: string) => void;
}
function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
saveServerData('overtime', data).catch(() => {});
}
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
try {
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
@@ -62,6 +59,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
},
createRecord: (data) => {
const previous = get().records;
const duration = calcDuration(data.startTime, data.endTime);
const record: OvertimeRecord = {
...data,
@@ -69,13 +67,19 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
duration,
createdAt: new Date().toISOString().slice(0, 10),
};
const records = [...get().records, record];
const records = [...previous, record];
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) => {
const records = get().records.map((r) => {
const previous = get().records;
const records = previous.map((r) => {
if (r.id !== id) return r;
const updated = { ...r, ...data };
if (data.startTime || data.endTime) {
@@ -84,31 +88,60 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
return updated;
});
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) => {
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 });
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) => {
const previous = get().reasons;
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 });
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) => {
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 });
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) => {
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 });
saveStored({ records: get().records, reasons });
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('overtime', { records: get().records, reasons }),
expected: reasons,
getCurrent: () => get().reasons,
rollback: () => set({ reasons: previous, loaded: true }),
});
},
}));

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand';
import { Product } from '@ftb/shared';
import { api } from '@/lib/api';
import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
import type { Stage, Role } from '@/lib/stage';
@@ -139,6 +140,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
},
createProduct: async (data) => {
const previousOverview = get().overview;
const newProduct: ProductOverview = {
id: `local-${Date.now()}`,
name: data.name || '',
@@ -149,46 +151,81 @@ export const useProductStore = create<ProductState>((set, get) => ({
versions: [],
_count: { requirements: 0, projects: 0, versions: 0 },
};
const updated = [newProduct, ...get().overview];
const updated = [newProduct, ...previousOverview];
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) => {
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,
);
const currentProduct = get().currentProduct;
set({
overview: updated,
overviewLoaded: true,
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) => {
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({
products: get().products.filter((p) => p.id !== id),
products: previousProducts.filter((p) => p.id !== id),
overview: updated,
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) => {
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[];
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) => {
const source = get().overview.find((p) => p.id === sourceId);
const target = get().overview.find((p) => p.id === targetId);
const previousOverview = get().overview;
const source = previousOverview.find((p) => p.id === sourceId);
const target = previousOverview.find((p) => p.id === targetId);
if (!source || !target) return;
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)
.map((p) => (p.id === targetId ? migratedTarget : p));
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) => {
const previousOverview = get().overview;
const newProject = {
id: `proj-${Date.now()}`,
name: data.name || '',
description: data.description || '',
createdAt: new Date().toISOString(),
};
const updated = get().overview.map((p) => {
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
@@ -225,11 +268,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
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) => {
const updated = get().overview.map((p) => {
const previousOverview = get().overview;
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
const currentProject = p.projects.find((proj) => proj.id === projectId);
if (!currentProject) return p;
@@ -262,10 +311,16 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
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) => {
const previousOverview = get().overview;
const newVersion: VersionItem = {
id: `ver-${Date.now()}`,
name: data.name,
@@ -274,7 +329,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
createdAt: new Date().toISOString(),
...(data as any),
};
const updated = get().overview.map((p) => {
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
@@ -283,11 +338,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
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) => {
const updated = get().overview.map((p) => {
const previousOverview = get().overview;
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
@@ -297,11 +358,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
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) => {
const updated = get().overview.map((p) => {
const previousOverview = get().overview;
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
@@ -310,11 +377,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
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) => {
const updated = get().overview.map((p) => {
const previousOverview = get().overview;
const updated = previousOverview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
@@ -323,7 +396,12 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated, overviewLoaded: true });
await saveStoredOverview(updated);
await saveWithOptimisticRollback({
save: () => saveStoredOverview(updated),
expected: updated,
getCurrent: () => get().overview,
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
});
},
}));

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand';
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
interface RequirementState {
@@ -55,12 +56,6 @@ const PRESET_PLATFORMS: DictItem[] = [
const MOCK_REQUIREMENTS: Requirement[] = [];
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> {
try {
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];
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.filter((r) => r.id !== id);
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 ---
@@ -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 updated = [...sourceTargets, item];
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.filter((s) => s.id !== id);
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 ---
@@ -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 updated = [...types, item];
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.filter((t) => t.id !== id);
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 ---
@@ -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 updated = [...platforms, item];
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
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) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.filter((p) => p.id !== id);
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 }),
});
},
};
});

View File

@@ -2,12 +2,9 @@
import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } 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';
function saveStored(items: TaskCategory[]) {
saveServerData('task-categories', items).catch(() => {});
}
let lastCategoriesFetchAt = 0;
async function loadStored(): Promise<TaskCategory[] | null> {
@@ -52,30 +49,53 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
};
const updated = [...list, item];
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) => {
const previous = get().categories;
const result = resolveAiTaskCategoryByName(get().categories, name, group, options);
if (result.created) {
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;
},
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', updated),
expected: updated,
getCurrent: () => get().categories,
rollback: () => set({ categories: previous, loaded: true }),
});
},
deleteCategory: (id) => {
const target = get().categories.find((c) => c.id === id);
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-categories', updated),
expected: updated,
getCurrent: () => get().categories,
rollback: () => set({ categories: previous, loaded: true }),
});
return true;
},
}));

View File

@@ -1,12 +1,9 @@
'use client';
import { create } from 'zustand';
import type { TaskWorklog } from '@/lib/task-worklog';
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
function saveStored(items: TaskWorklog[]) {
saveServerData('task-worklogs', items).catch(() => {});
}
let lastWorklogsFetchAt = 0;
async function loadStored(): Promise<TaskWorklog[] | null> {
@@ -38,20 +35,32 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
},
addWorklog: (data) => {
const previous = get().worklogs;
const item: TaskWorklog = {
...data,
id: `wl-${Date.now()}`,
createdAt: new Date().toISOString(),
};
const updated = [...get().worklogs, item];
const updated = [...previous, item];
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) => {
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('task-worklogs', updated),
expected: updated,
getCurrent: () => get().worklogs,
rollback: () => set({ worklogs: previous, loaded: true }),
});
},
getActualHours: (taskId) => {

View File

@@ -4,6 +4,7 @@ import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-c
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
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 {
makeTestCaseCreatedActivity,
@@ -69,7 +70,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
} as TestCase);
const updated = [...list, tc];
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));
return tc;
},
@@ -77,6 +83,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
createTestCases: (items) => {
if (items.length === 0) return [];
let list = get().testCases;
const previous = list;
const now = new Date().toISOString();
const created: TestCase[] = [];
for (const data of items) {
@@ -92,7 +99,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
list = [...list, tc];
}
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) => {
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
});
@@ -100,17 +112,29 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
},
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,
);
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) => {
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 });
saveStored(updated);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('test-cases', updated),
expected: updated,
getCurrent: () => get().testCases,
rollback: () => set({ testCases: previous, loaded: true }),
});
},
changeStatus: (id, to, extra) => {

View File

@@ -1,6 +1,7 @@
'use client';
import { create } from 'zustand';
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 { getPlanCompletionState } 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[] = [];
let lastPlansFetchAt = 0;
function saveStored(plans: VersionPlan[]) {
saveServerData('version-plans', plans).catch(() => {});
}
async function loadStored(): Promise<VersionPlan[] | null> {
try {
return await loadServerData<VersionPlan[]>('version-plans');
@@ -51,17 +48,24 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
},
createPlan: (data) => {
const previous = get().plans;
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 });
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));
},
updatePlan: (id, data) => {
const previous = get().plans;
const now = new Date().toISOString();
const activities: WorkActivityDraft[] = [];
const plans = get().plans.map((p) => {
const plans = previous.map((p) => {
if (p.id !== id) return p;
const patch = { ...data };
if (patch.status === 'in_progress' && !p.actualStartAt) {
@@ -88,13 +92,19 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
return next;
});
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));
},
completePlan: (id, result) => {
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;
const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
const state = getPlanCompletionState(next);
@@ -107,13 +117,24 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
return next;
});
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;
},
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 });
saveStored(plans);
scheduleSaveWithOptimisticRollback({
save: () => saveServerData('version-plans', plans),
expected: plans,
getCurrent: () => get().plans,
rollback: () => set({ plans: previous, loaded: true }),
});
},
}));