refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { loadServerData } from '@/lib/server-data';
|
||||
import type { Member } from '@/lib/members';
|
||||
import { ensureMemberUsernames } from '@/lib/members';
|
||||
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
|
||||
import { findMemberByUsernameCredentials, resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
|
||||
import { listMembersDomain } from '@/lib/domain-api';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
|
||||
interface AuthState {
|
||||
@@ -17,10 +17,6 @@ interface AuthState {
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface StoredMembersData {
|
||||
members: Member[];
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'ftb_auth_session';
|
||||
const PERSIST_KEY = 'ftb_auth_persist';
|
||||
|
||||
@@ -40,9 +36,9 @@ function clearAuthStorage() {
|
||||
|
||||
async function loadMembersForAuth(): Promise<Member[]> {
|
||||
try {
|
||||
const stored = await loadServerData<StoredMembersData>('members');
|
||||
if (stored?.members?.length) {
|
||||
const ensured = ensureSystemAdminMember(stored.members);
|
||||
const domainMembers = await listMembersDomain();
|
||||
if (domainMembers.length) {
|
||||
const ensured = ensureSystemAdminMember(domainMembers);
|
||||
return ensureMemberUsernames(ensured.members).members;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -12,10 +12,9 @@ import {
|
||||
updateBugStatusByVersionId,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import type { WorkActivity } from '@/lib/work-activity';
|
||||
import {
|
||||
makeBugCreatedActivity,
|
||||
makeBugStatusActivity,
|
||||
makeBugTransferredActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
@@ -23,13 +22,6 @@ import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
let lastBugsFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<Bug[] | null> {
|
||||
try {
|
||||
return await loadServerData<Bug[]>('bugs');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeLog(action: BugLog['action'], operator: string, from?: string, to?: string, remark?: string): BugLog {
|
||||
return { id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, action, fromValue: from, toValue: to, operator, remark, createdAt: new Date().toISOString() };
|
||||
}
|
||||
@@ -53,13 +45,18 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
loaded: false,
|
||||
|
||||
fetchBugs: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = options?.versionId
|
||||
? await listBugsByVersionId(options.versionId).catch(loadStored)
|
||||
: await loadStored();
|
||||
if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
? await listBugsByVersionId(options.versionId).catch(() => [])
|
||||
: get().bugs;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastBugsFetchAt = Date.now();
|
||||
set({ bugs: cached ?? [], loaded: true });
|
||||
set({
|
||||
bugs: options?.versionId
|
||||
? mergeBugsForVersion(get().bugs, options.versionId, cached ?? [])
|
||||
: cached ?? [],
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createBug: (data, operator) => {
|
||||
@@ -79,17 +76,12 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
set({ bugs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const result = await createBugByVersionId(bug.versionId, bug);
|
||||
set({
|
||||
bugs: get().bugs.map((item) => (item.id === bug.id ? { ...bug, ...result.item, logs: bug.logs } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveBugsFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
||||
}
|
||||
const result = await createBugByVersionId(bug.versionId, bug);
|
||||
set({
|
||||
bugs: get().bugs.map((item) => (item.id === bug.id ? { ...bug, ...result.item, logs: bug.logs } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().bugs,
|
||||
@@ -106,14 +98,10 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
set({ bugs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((bug) => bug.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateBugByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveBugsFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((bug) => bug.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateBugByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().bugs,
|
||||
@@ -127,13 +115,9 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
set({ bugs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((bug) => bug.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteBugByVersionId(versionId, id);
|
||||
} catch {
|
||||
await saveBugsFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((bug) => bug.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteBugByVersionId(versionId, id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().bugs,
|
||||
@@ -157,17 +141,12 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
const activity = makeBugStatusActivity(bug, bug.status, to, operator);
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const result = await updateBugStatusByVersionId(bug.versionId, id, to, {
|
||||
operator,
|
||||
resolution: extra?.resolution,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
} catch {
|
||||
await saveBugsFallback(updated);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
}
|
||||
const result = await updateBugStatusByVersionId(bug.versionId, id, to, {
|
||||
operator,
|
||||
resolution: extra?.resolution,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().bugs,
|
||||
@@ -189,14 +168,9 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
set({ bugs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const result = await transferBugByVersionId(bug.versionId, id, newAssigneeId, operator);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
|
||||
} catch {
|
||||
await saveBugsFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(activity);
|
||||
}
|
||||
const result = await transferBugByVersionId(bug.versionId, id, newAssigneeId, operator);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().bugs,
|
||||
@@ -218,8 +192,11 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveBugsFallback(bugs: Bug[]) {
|
||||
await saveServerData('bugs', bugs);
|
||||
function mergeBugsForVersion(current: Bug[], versionId: string, scopedBugs: Bug[]) {
|
||||
return [
|
||||
...current.filter((bug) => bug.versionId !== versionId),
|
||||
...scopedBugs,
|
||||
];
|
||||
}
|
||||
|
||||
function appendDomainActivities(activities: WorkActivity[]) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||
import { generateTaskNo } from '@/lib/dev-task';
|
||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||
import {
|
||||
createDevTaskByVersionId,
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
updateDevTaskByVersionId,
|
||||
updateDevTaskStatusByVersionId,
|
||||
} from '@/lib/domain-api';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
import { createEntityId } from '@/lib/entity-id';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import type { WorkActivity } from '@/lib/work-activity';
|
||||
import {
|
||||
makeDevTaskBlockedActivity,
|
||||
@@ -23,27 +23,8 @@ import {
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: DevTask[]) {
|
||||
saveServerData('dev-tasks', items).catch(() => {});
|
||||
}
|
||||
|
||||
let lastTasksFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<DevTask[] | null> {
|
||||
try {
|
||||
const parsed = await loadServerData<DevTask[]>('dev-tasks');
|
||||
if (parsed) {
|
||||
if (Array.isArray(parsed) && parsed.some(isLegacyTask)) {
|
||||
return null;
|
||||
}
|
||||
const result = dedupeEntityIds(parsed, 'task');
|
||||
if (result.changed) saveStored(result.items);
|
||||
return result.items;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DevTaskState {
|
||||
tasks: DevTask[];
|
||||
loaded: boolean;
|
||||
@@ -63,13 +44,18 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
loaded: false,
|
||||
|
||||
fetchTasks: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = options?.versionId
|
||||
? await listDevTasksByVersionId(options.versionId).catch(loadStored)
|
||||
: await loadStored();
|
||||
if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
? await listDevTasksByVersionId(options.versionId).catch(() => [])
|
||||
: get().tasks;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastTasksFetchAt = Date.now();
|
||||
set({ tasks: cached ?? [], loaded: true });
|
||||
set({
|
||||
tasks: options?.versionId
|
||||
? mergeDevTasksForVersion(get().tasks, options.versionId, cached ?? [])
|
||||
: cached ?? [],
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createTask: (data) => {
|
||||
@@ -87,18 +73,13 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
set({ tasks: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await createDevTaskByVersionId(task.versionId, task);
|
||||
set({
|
||||
tasks: get().tasks.map((item) => (item.id === task.id ? { ...task, ...result.item } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||
}
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await createDevTaskByVersionId(task.versionId, task);
|
||||
set({
|
||||
tasks: get().tasks.map((item) => (item.id === task.id ? { ...task, ...result.item } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().tasks,
|
||||
@@ -115,14 +96,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
set({ tasks: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((task) => task.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateDevTaskByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((task) => task.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateDevTaskByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().tasks,
|
||||
@@ -136,13 +113,9 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
set({ tasks: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((task) => task.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteDevTaskByVersionId(versionId, id);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((task) => task.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteDevTaskByVersionId(versionId, id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().tasks,
|
||||
@@ -166,15 +139,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId);
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await updateDevTaskStatusByVersionId(task.versionId, id, to);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
}
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await updateDevTaskStatusByVersionId(task.versionId, id, to);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().tasks,
|
||||
@@ -201,15 +169,10 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
: makeDevTaskUnblockedActivity(task, task.assigneeId);
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await setDevTaskBlockedByVersionId(task.versionId, id, blocked, reason);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
|
||||
} catch {
|
||||
await saveDevTasksFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(activity);
|
||||
}
|
||||
if (!task.versionId) throw new Error('missing versionId');
|
||||
const result = await setDevTaskBlockedByVersionId(task.versionId, id, blocked, reason);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().tasks,
|
||||
@@ -231,8 +194,11 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveDevTasksFallback(tasks: DevTask[]) {
|
||||
await saveServerData('dev-tasks', tasks);
|
||||
function mergeDevTasksForVersion(current: DevTask[], versionId: string, scopedTasks: DevTask[]) {
|
||||
return [
|
||||
...current.filter((task) => task.versionId !== versionId),
|
||||
...scopedTasks,
|
||||
];
|
||||
}
|
||||
|
||||
function appendDomainActivities(activities: WorkActivity[]) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
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';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS, SERVER_DATA_SAVE_ERROR_EVENT } from '@/lib/server-data';
|
||||
|
||||
const PRESET_DEPARTMENTS: Department[] = [
|
||||
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
|
||||
@@ -37,6 +37,19 @@ const MOCK_MEMBERS: Member[] = [
|
||||
];
|
||||
let lastMembersFetchAt = 0;
|
||||
|
||||
type StoredMemberConfig = {
|
||||
departments?: Department[];
|
||||
members?: Member[];
|
||||
roles?: RoleItem[];
|
||||
passwordRule?: PasswordRule;
|
||||
};
|
||||
|
||||
type MemberConfigState = {
|
||||
departments: Department[];
|
||||
roles: RoleItem[];
|
||||
passwordRule: PasswordRule;
|
||||
};
|
||||
|
||||
interface MemberState {
|
||||
departments: Department[];
|
||||
members: Member[];
|
||||
@@ -57,13 +70,13 @@ interface MemberState {
|
||||
deleteRole: (id: string) => void;
|
||||
}
|
||||
|
||||
async function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
|
||||
await saveServerData('members', state);
|
||||
async function saveStored(state: MemberConfigState) {
|
||||
await saveServerData('members', { departments: state.departments, members: [], roles: state.roles, passwordRule: state.passwordRule });
|
||||
}
|
||||
|
||||
async function loadStored(): Promise<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null> {
|
||||
async function loadStored(): Promise<StoredMemberConfig | null> {
|
||||
try {
|
||||
return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members');
|
||||
return await loadServerData<StoredMemberConfig>('members');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -86,7 +99,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
lastMembersFetchAt = Date.now();
|
||||
if (cached) {
|
||||
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
|
||||
const migratedRoles = cached.roles.map((r) => {
|
||||
const configuredRoles = Array.isArray(cached.roles) ? cached.roles : PRESET_ROLES;
|
||||
const migratedRoles = configuredRoles.map((r) => {
|
||||
if (!('permissions' in r) || !Array.isArray((r as any).permissions)) {
|
||||
return { ...r, permissions: DEFAULT_ROLE_PERMISSIONS[r.id] ?? [] };
|
||||
}
|
||||
@@ -98,23 +112,21 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const roles = rolesWithNewDefaults.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
|
||||
? rolesWithNewDefaults.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
|
||||
: [adminRole, ...rolesWithNewDefaults];
|
||||
const rolesChanged =
|
||||
permissionMerged.changed ||
|
||||
roles.length !== rolesWithNewDefaults.length ||
|
||||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
|
||||
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : cached.members;
|
||||
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : get().members;
|
||||
const ensured = ensureSystemAdminMember(sourceMembers);
|
||||
const usernameEnsured = ensureMemberUsernames(ensured.members);
|
||||
const next = {
|
||||
departments: cached.departments,
|
||||
departments: Array.isArray(cached.departments) ? cached.departments : PRESET_DEPARTMENTS,
|
||||
members: usernameEnsured.members,
|
||||
roles,
|
||||
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
||||
};
|
||||
set({ ...next, loaded: true });
|
||||
if (ensured.changed || usernameEnsured.changed || rolesChanged) void saveStored(next).catch(() => {});
|
||||
} else {
|
||||
set({ members: domainMembers && domainMembers.length > 0 ? domainMembers : get().members, loaded: true });
|
||||
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : get().members;
|
||||
const ensured = ensureSystemAdminMember(sourceMembers);
|
||||
const usernameEnsured = ensureMemberUsernames(ensured.members);
|
||||
set({ members: usernameEnsured.members, loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,7 +134,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const previous = get().passwordRule;
|
||||
set({ passwordRule: rule });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }),
|
||||
save: () => saveStored({ departments: get().departments, roles: get().roles, passwordRule: rule }),
|
||||
expected: rule,
|
||||
getCurrent: () => get().passwordRule,
|
||||
rollback: () => set({ passwordRule: previous }),
|
||||
@@ -135,7 +147,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const departments = [...previous, dept];
|
||||
set({ departments });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
expected: departments,
|
||||
getCurrent: () => get().departments,
|
||||
rollback: () => set({ departments: previous }),
|
||||
@@ -146,7 +158,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const departments = previous.map((d) => d.id === id ? { ...d, ...data } : d);
|
||||
set({ departments });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
expected: departments,
|
||||
getCurrent: () => get().departments,
|
||||
rollback: () => set({ departments: previous }),
|
||||
@@ -157,7 +169,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const departments = previous.filter((d) => d.id !== id && d.parentId !== id);
|
||||
set({ departments });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
expected: departments,
|
||||
getCurrent: () => get().departments,
|
||||
rollback: () => set({ departments: previous }),
|
||||
@@ -182,8 +194,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
set({
|
||||
members: get().members.map((item) => (item.id === member.id ? { ...member, ...created } : item)),
|
||||
});
|
||||
} catch {
|
||||
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
} catch (error) {
|
||||
notifyMemberSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: members,
|
||||
@@ -200,12 +213,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
try {
|
||||
await updateMemberDomain(id, safeData);
|
||||
} catch (error) {
|
||||
try {
|
||||
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
} catch {
|
||||
set({ members: previousMembers });
|
||||
throw error;
|
||||
}
|
||||
set({ members: previousMembers });
|
||||
notifyMemberSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteMember: (id) => {
|
||||
@@ -217,8 +227,9 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteMemberDomain(id);
|
||||
} catch {
|
||||
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
} catch (error) {
|
||||
notifyMemberSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: members,
|
||||
@@ -238,7 +249,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const roles = [...previous, role];
|
||||
set({ roles });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
|
||||
expected: roles,
|
||||
getCurrent: () => get().roles,
|
||||
rollback: () => set({ roles: previous }),
|
||||
@@ -257,7 +268,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
});
|
||||
set({ roles });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
|
||||
expected: roles,
|
||||
getCurrent: () => get().roles,
|
||||
rollback: () => set({ roles: previous }),
|
||||
@@ -269,10 +280,22 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const roles = previous.filter((r) => r.id !== id);
|
||||
set({ roles });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }),
|
||||
save: () => saveStored({ departments: get().departments, roles, passwordRule: get().passwordRule }),
|
||||
expected: roles,
|
||||
getCurrent: () => get().roles,
|
||||
rollback: () => set({ roles: previous }),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
function notifyMemberSaveFailure(error: unknown) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
|
||||
detail: {
|
||||
key: 'members',
|
||||
message: error instanceof Error && error.message ? error.message : '成员/账号保存失败,请检查 API 服务',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,10 @@ interface OvertimeState {
|
||||
deleteReason: (id: string) => void;
|
||||
}
|
||||
|
||||
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
|
||||
async function loadStoredReasons(): Promise<DictItem[] | null> {
|
||||
try {
|
||||
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
|
||||
const stored = await loadServerData<{ records?: OvertimeRecord[]; reasons?: DictItem[] }>('overtime');
|
||||
return Array.isArray(stored?.reasons) ? stored.reasons : null;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -58,15 +59,15 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
|
||||
fetchRecords: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const [cached, domainRecords] = await Promise.all([
|
||||
loadStored(),
|
||||
const [storedReasons, domainRecords] = await Promise.all([
|
||||
loadStoredReasons(),
|
||||
listOvertimeRecordsDomain().catch(() => null),
|
||||
]);
|
||||
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastOvertimeFetchAt = Date.now();
|
||||
set({
|
||||
records: domainRecords && domainRecords.length > 0 ? domainRecords : cached?.records ?? MOCK_RECORDS,
|
||||
reasons: cached?.reasons ?? PRESET_REASONS,
|
||||
records: domainRecords ?? MOCK_RECORDS,
|
||||
reasons: storedReasons ?? PRESET_REASONS,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
@@ -84,15 +85,11 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createOvertimeRecordDomain(record);
|
||||
set({
|
||||
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
const created = await createOvertimeRecordDomain(record);
|
||||
set({
|
||||
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
@@ -113,11 +110,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
@@ -131,11 +124,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteOvertimeRecordDomain(id);
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
await deleteOvertimeRecordDomain(id);
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
@@ -149,7 +138,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
const reasons = [...previous, item];
|
||||
set({ reasons, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||
save: () => saveOvertimeReasons(reasons),
|
||||
expected: reasons,
|
||||
getCurrent: () => get().reasons,
|
||||
rollback: () => set({ reasons: previous, loaded: true }),
|
||||
@@ -161,7 +150,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
const reasons = previous.map((r) => r.id === id ? { ...r, name } : r);
|
||||
set({ reasons, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||
save: () => saveOvertimeReasons(reasons),
|
||||
expected: reasons,
|
||||
getCurrent: () => get().reasons,
|
||||
rollback: () => set({ reasons: previous, loaded: true }),
|
||||
@@ -173,7 +162,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
const reasons = previous.filter((r) => r.id !== id);
|
||||
set({ reasons, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records: get().records, reasons }),
|
||||
save: () => saveOvertimeReasons(reasons),
|
||||
expected: reasons,
|
||||
getCurrent: () => get().reasons,
|
||||
rollback: () => set({ reasons: previous, loaded: true }),
|
||||
@@ -181,6 +170,6 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveOvertimeFallback(records: OvertimeRecord[], reasons: DictItem[]) {
|
||||
await saveServerData('overtime', { records, reasons });
|
||||
async function saveOvertimeReasons(reasons: DictItem[]) {
|
||||
await saveServerData('overtime', { records: [], reasons });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
updateVersionByProductId,
|
||||
} from '@/lib/domain-api';
|
||||
import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import type { Stage, Role } from '@/lib/stage';
|
||||
import type { Priority, VersionLinks } from '@/lib/derive';
|
||||
|
||||
@@ -99,11 +99,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
const products = await api.get<ProductWithCount[]>('/products');
|
||||
set({ products, loading: false });
|
||||
} catch {
|
||||
const overview = await loadStoredOverview();
|
||||
const products = overview
|
||||
? overview.map(({ projects: _projects, versions: _versions, ...product }) => product)
|
||||
: [];
|
||||
set({ products, error: null, loading: false });
|
||||
set({ products: [], error: '产品加载失败', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -115,9 +111,8 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
const overview = await api.get<ProductOverview[]>('/products/overview');
|
||||
set({ overview, loading: false, overviewLoaded: true });
|
||||
} catch {
|
||||
const cached = await loadStoredOverview();
|
||||
if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
set({ overview: cached ?? MOCK_OVERVIEW, error: null, loading: false, overviewLoaded: true });
|
||||
set({ overview: get().overviewLoaded ? get().overview : MOCK_OVERVIEW, error: '产品概览加载失败', loading: false, overviewLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,8 +122,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
const product = await api.get<Product>(`/products/${id}`);
|
||||
set({ currentProduct: product, loading: false });
|
||||
} catch {
|
||||
const overview = await loadStoredOverview();
|
||||
const found = overview?.find((p) => p.id === id) ?? get().overview.find((p) => p.id === id);
|
||||
const found = get().overview.find((p) => p.id === id);
|
||||
if (found) {
|
||||
set({ currentProduct: found as any, loading: false });
|
||||
} else {
|
||||
@@ -153,16 +147,12 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createProductRoot(data);
|
||||
set({
|
||||
overview: get().overview.map((product) => (product.id === newProduct.id ? created as ProductOverview : product)),
|
||||
products: [created, ...get().products] as ProductWithCount[],
|
||||
overviewLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
const created = await createProductRoot(data);
|
||||
set({
|
||||
overview: get().overview.map((product) => (product.id === newProduct.id ? created as ProductOverview : product)),
|
||||
products: [created, ...get().products] as ProductWithCount[],
|
||||
overviewLoaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -184,11 +174,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
});
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await updateProductRoot(id, data);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await updateProductRoot(id, data);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -213,11 +199,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
});
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteProductRoot(id);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await deleteProductRoot(id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -236,7 +218,9 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
||||
set({ overview: reordered, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: () => saveStoredOverview(reordered),
|
||||
save: async () => {
|
||||
throw new Error('Product reorder domain API is not available');
|
||||
},
|
||||
expected: reordered,
|
||||
getCurrent: () => get().overview,
|
||||
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||
@@ -265,7 +249,9 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
.map((p) => (p.id === targetId ? migratedTarget : p));
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: () => saveStoredOverview(updated),
|
||||
save: async () => {
|
||||
throw new Error('Product merge domain API is not available');
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
rollback: () => set({ overview: previousOverview, overviewLoaded: true }),
|
||||
@@ -291,22 +277,18 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createProjectByProductId(productId, data);
|
||||
set({
|
||||
overview: get().overview.map((p) => {
|
||||
if (p.id !== productId) return p;
|
||||
return {
|
||||
...p,
|
||||
projects: p.projects.map((project) => (project.id === newProject.id ? created : project)),
|
||||
_count: { ...p._count, projects: p.projects.length } as any,
|
||||
};
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
const created = await createProjectByProductId(productId, data);
|
||||
set({
|
||||
overview: get().overview.map((p) => {
|
||||
if (p.id !== productId) return p;
|
||||
return {
|
||||
...p,
|
||||
projects: p.projects.map((project) => (project.id === newProject.id ? created : project)),
|
||||
_count: { ...p._count, projects: p.projects.length } as any,
|
||||
};
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -351,11 +333,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await updateProjectByProductId(productId, projectId, data);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await updateProjectByProductId(productId, projectId, data);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -384,22 +362,18 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createVersionByProductId(productId, data);
|
||||
set({
|
||||
overview: get().overview.map((p) => {
|
||||
if (p.id !== productId) return p;
|
||||
return {
|
||||
...p,
|
||||
versions: p.versions.map((version) => (version.id === newVersion.id ? created as VersionItem : version)),
|
||||
_count: { ...p._count, versions: p.versions.length } as any,
|
||||
};
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
const created = await createVersionByProductId(productId, data);
|
||||
set({
|
||||
overview: get().overview.map((p) => {
|
||||
if (p.id !== productId) return p;
|
||||
return {
|
||||
...p,
|
||||
versions: p.versions.map((version) => (version.id === newVersion.id ? created as VersionItem : version)),
|
||||
_count: { ...p._count, versions: p.versions.length } as any,
|
||||
};
|
||||
}),
|
||||
overviewLoaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -421,11 +395,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await updateVersionByProductId(productId, versionId, data);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await updateVersionByProductId(productId, versionId, data);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -446,11 +416,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteVersionByProductId(productId, versionId);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await deleteVersionByProductId(productId, versionId);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -471,11 +437,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
set({ overview: updated, overviewLoaded: true });
|
||||
await saveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteProjectByProductId(productId, projectId);
|
||||
} catch {
|
||||
await saveRootOverviewFallback(updated);
|
||||
}
|
||||
await deleteProjectByProductId(productId, projectId);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().overview,
|
||||
@@ -483,18 +445,3 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveStoredOverview(data: ProductOverview[]) {
|
||||
await saveServerData('products-overview', data);
|
||||
}
|
||||
|
||||
async function saveRootOverviewFallback(data: ProductOverview[]) {
|
||||
await saveStoredOverview(data);
|
||||
}
|
||||
|
||||
async function loadStoredOverview(): Promise<ProductOverview[] | null> {
|
||||
try {
|
||||
return await loadServerData<ProductOverview[]>('products-overview');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
updateRequirementByProductId,
|
||||
updateRequirementStatusByProductId,
|
||||
} from '@/lib/domain-api';
|
||||
import { api } from '@/lib/api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { loadV22RequirementsPage, type V22RequirementsQuery } from '@/lib/v22-api';
|
||||
import { useAuthStore } from './useAuthStore';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
|
||||
interface RequirementState {
|
||||
requirements: Requirement[];
|
||||
@@ -19,7 +23,7 @@ interface RequirementState {
|
||||
platforms: DictItem[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
fetchRequirements: (options?: { force?: boolean }) => Promise<void>;
|
||||
fetchRequirements: (options?: { force?: boolean; productId?: string; projectId?: string; versionId?: string }) => Promise<void>;
|
||||
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
|
||||
updateRequirement: (id: string, data: Partial<Requirement>) => void;
|
||||
deleteRequirement: (id: string, productId?: string) => void;
|
||||
@@ -62,11 +66,13 @@ const PRESET_PLATFORMS: DictItem[] = [
|
||||
const MOCK_REQUIREMENTS: Requirement[] = [];
|
||||
let lastRequirementsFetchAt = 0;
|
||||
|
||||
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');
|
||||
} catch {}
|
||||
return null;
|
||||
type RequirementGovernanceKind = 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
||||
|
||||
interface GovernanceDictionaryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
group?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// --- Store ---
|
||||
@@ -81,19 +87,27 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
loaded: false,
|
||||
|
||||
fetchRequirements: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const hasRelationScope = Boolean(options?.productId);
|
||||
if (!hasRelationScope && !options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const [governed, scopedRequirements] = await Promise.all([
|
||||
loadRequirementGovernanceDictionaries().catch(() => null),
|
||||
hasRelationScope ? loadScopedRequirementsFromRelation(options).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
if (!hasRelationScope && !options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastRequirementsFetchAt = Date.now();
|
||||
if (cached) {
|
||||
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms, loaded: true });
|
||||
} else {
|
||||
set({ loaded: true });
|
||||
}
|
||||
set({
|
||||
requirements: scopedRequirements
|
||||
? mergeRequirementsForScope(get().requirements, options, scopedRequirements)
|
||||
: get().requirements,
|
||||
sourceTargets: governed?.sourceTargets ?? PRESET_SOURCE_TARGETS,
|
||||
types: governed?.types ?? PRESET_TYPES,
|
||||
platforms: governed?.platforms ?? PRESET_PLATFORMS,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createRequirement: (data) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { requirements } = get();
|
||||
const code = `REQ-${String(requirements.length + 1).padStart(3, '0')}`;
|
||||
const newReq: Requirement = {
|
||||
...data,
|
||||
@@ -106,17 +120,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
set({ requirements: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createRequirementByProductId(newReq.productId, newReq);
|
||||
set({
|
||||
requirements: get().requirements.map((item) => (
|
||||
item.id === newReq.id ? { ...created, creator: newReq.creator || created.creator } : item
|
||||
)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
|
||||
}
|
||||
const created = await createRequirementByProductId(newReq.productId, newReq);
|
||||
set({
|
||||
requirements: get().requirements.map((item) => (
|
||||
item.id === newReq.id ? { ...created, creator: newReq.creator || created.creator } : item
|
||||
)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().requirements,
|
||||
@@ -125,29 +135,24 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
updateRequirement: (id, data) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { requirements } = get();
|
||||
const current = requirements.find((r) => r.id === id);
|
||||
const productId = data.productId ?? current?.productId;
|
||||
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
|
||||
set({ requirements: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!productId) throw new Error('missing requirement productId');
|
||||
const domainPatch = { ...data };
|
||||
const status = domainPatch.status;
|
||||
delete domainPatch.status;
|
||||
delete domainPatch.productId;
|
||||
delete domainPatch.productOwner;
|
||||
delete domainPatch.addedToVersionBy;
|
||||
if (hasDomainPatch(domainPatch)) {
|
||||
await updateRequirementByProductId(productId, id, domainPatch);
|
||||
}
|
||||
if (status) {
|
||||
await updateRequirementStatusByProductId(productId, id, status);
|
||||
}
|
||||
} catch {
|
||||
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
|
||||
if (!productId) throw new Error('missing requirement productId');
|
||||
const domainPatch = { ...data };
|
||||
const status = domainPatch.status;
|
||||
delete domainPatch.status;
|
||||
delete domainPatch.productId;
|
||||
delete domainPatch.addedToVersionBy;
|
||||
if (hasDomainPatch(domainPatch)) {
|
||||
await updateRequirementByProductId(productId, id, domainPatch);
|
||||
}
|
||||
if (status) {
|
||||
await updateRequirementStatusByProductId(productId, id, status);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
@@ -157,18 +162,14 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteRequirement: (id, scopedProductId) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { requirements } = get();
|
||||
const productId = scopedProductId ?? requirements.find((r) => r.id === id)?.productId;
|
||||
const updated = requirements.filter((r) => r.id !== id);
|
||||
set({ requirements: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!productId) throw new Error('missing requirement productId');
|
||||
await deleteRequirementByProductId(productId, id);
|
||||
} catch {
|
||||
await saveRequirementsFallback({ requirements: updated, sourceTargets, types, platforms });
|
||||
}
|
||||
if (!productId) throw new Error('missing requirement productId');
|
||||
await deleteRequirementByProductId(productId, id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().requirements,
|
||||
@@ -178,12 +179,20 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
|
||||
// --- SourceTarget dict ---
|
||||
addSourceTarget: (name, sourceType) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const { sourceTargets } = get();
|
||||
const updated = [...sourceTargets, item];
|
||||
set({ sourceTargets: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||
save: async () => {
|
||||
const created = await createGovernanceDictionary('requirement_source', name, sourceType);
|
||||
set({
|
||||
sourceTargets: get().sourceTargets.map((target) => (
|
||||
target.id === item.id ? governanceRowToSourceTarget(created, sourceType) : target
|
||||
)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().sourceTargets,
|
||||
rollback: () => set({ sourceTargets, loaded: true }),
|
||||
@@ -191,11 +200,14 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
updateSourceTarget: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { sourceTargets } = get();
|
||||
const current = sourceTargets.find((target) => target.id === id);
|
||||
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
|
||||
set({ sourceTargets: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||
save: async () => {
|
||||
await updateGovernanceDictionary('requirement_source', id, name, current?.sourceType ?? 'customer');
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().sourceTargets,
|
||||
rollback: () => set({ sourceTargets, loaded: true }),
|
||||
@@ -203,11 +215,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteSourceTarget: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { sourceTargets } = get();
|
||||
const updated = sourceTargets.filter((s) => s.id !== id);
|
||||
set({ sourceTargets: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }),
|
||||
save: async () => {
|
||||
await deleteGovernanceDictionary('requirement_source', id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().sourceTargets,
|
||||
rollback: () => set({ sourceTargets, loaded: true }),
|
||||
@@ -216,12 +230,18 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
|
||||
// --- Type dict ---
|
||||
addType: (name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const { types } = get();
|
||||
const updated = [...types, item];
|
||||
set({ types: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||
save: async () => {
|
||||
const created = await createGovernanceDictionary('requirement_type', name);
|
||||
set({
|
||||
types: get().types.map((type) => (type.id === item.id ? governanceRowToDictItem(created) : type)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().types,
|
||||
rollback: () => set({ types, loaded: true }),
|
||||
@@ -229,11 +249,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
updateType: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { types } = get();
|
||||
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
|
||||
set({ types: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||
save: async () => {
|
||||
await updateGovernanceDictionary('requirement_type', id, name);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().types,
|
||||
rollback: () => set({ types, loaded: true }),
|
||||
@@ -241,11 +263,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteType: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { types } = get();
|
||||
const updated = types.filter((t) => t.id !== id);
|
||||
set({ types: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }),
|
||||
save: async () => {
|
||||
await deleteGovernanceDictionary('requirement_type', id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().types,
|
||||
rollback: () => set({ types, loaded: true }),
|
||||
@@ -254,12 +278,18 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
|
||||
// --- Platform dict ---
|
||||
addPlatform: (name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const { platforms } = get();
|
||||
const updated = [...platforms, item];
|
||||
set({ platforms: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||
save: async () => {
|
||||
const created = await createGovernanceDictionary('requirement_platform', name);
|
||||
set({
|
||||
platforms: get().platforms.map((platform) => (platform.id === item.id ? governanceRowToDictItem(created) : platform)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().platforms,
|
||||
rollback: () => set({ platforms, loaded: true }),
|
||||
@@ -267,11 +297,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
updatePlatform: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { platforms } = get();
|
||||
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
|
||||
set({ platforms: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||
save: async () => {
|
||||
await updateGovernanceDictionary('requirement_platform', id, name);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().platforms,
|
||||
rollback: () => set({ platforms, loaded: true }),
|
||||
@@ -279,11 +311,13 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
},
|
||||
|
||||
deletePlatform: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const { platforms } = get();
|
||||
const updated = platforms.filter((p) => p.id !== id);
|
||||
set({ platforms: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }),
|
||||
save: async () => {
|
||||
await deleteGovernanceDictionary('requirement_platform', id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().platforms,
|
||||
rollback: () => set({ platforms, loaded: true }),
|
||||
@@ -292,15 +326,114 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
};
|
||||
});
|
||||
|
||||
async function saveRequirementsFallback(data: {
|
||||
requirements: Requirement[];
|
||||
sourceTargets: SourceTarget[];
|
||||
types: DictItem[];
|
||||
platforms: DictItem[];
|
||||
}) {
|
||||
await saveServerData('requirements', data);
|
||||
}
|
||||
|
||||
function hasDomainPatch(data: Partial<Requirement>) {
|
||||
return Object.values(data).some((value) => value !== undefined);
|
||||
}
|
||||
|
||||
async function loadScopedRequirementsFromRelation(
|
||||
options?: { productId?: string; projectId?: string; versionId?: string },
|
||||
): Promise<Requirement[]> {
|
||||
if (!options?.productId) return [];
|
||||
const items: Requirement[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const query: V22RequirementsQuery = {
|
||||
productId: options.productId,
|
||||
projectId: options.projectId,
|
||||
versionId: options.versionId,
|
||||
sort: 'created_at_desc',
|
||||
limit: 500,
|
||||
cursor,
|
||||
};
|
||||
const page = await loadV22RequirementsPage(query);
|
||||
items.push(...page.items);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
return items;
|
||||
}
|
||||
|
||||
function mergeRequirementsForScope(
|
||||
current: Requirement[],
|
||||
scope: { productId?: string; projectId?: string; versionId?: string } | undefined,
|
||||
scopedRequirements: Requirement[],
|
||||
) {
|
||||
if (!scope?.productId) return current;
|
||||
return [
|
||||
...current.filter((requirement) => !isRequirementInScope(requirement, scope)),
|
||||
...scopedRequirements,
|
||||
];
|
||||
}
|
||||
|
||||
function isRequirementInScope(
|
||||
requirement: Requirement,
|
||||
scope: { productId?: string; projectId?: string; versionId?: string },
|
||||
) {
|
||||
if (scope.versionId) return requirement.versionId === scope.versionId;
|
||||
if (scope.projectId) return requirement.productId === scope.productId && requirement.projectId === scope.projectId;
|
||||
return requirement.productId === scope.productId;
|
||||
}
|
||||
|
||||
async function loadRequirementGovernanceDictionaries() {
|
||||
const [types, platforms, sourceTargets] = await Promise.all([
|
||||
listGovernanceDictionary('requirement_type'),
|
||||
listGovernanceDictionary('requirement_platform'),
|
||||
listGovernanceDictionary('requirement_source'),
|
||||
]);
|
||||
return {
|
||||
types: types.length > 0 ? types.map(governanceRowToDictItem) : PRESET_TYPES,
|
||||
platforms: platforms.length > 0 ? platforms.map(governanceRowToDictItem) : PRESET_PLATFORMS,
|
||||
sourceTargets: sourceTargets.length > 0 ? sourceTargets.map((row) => governanceRowToSourceTarget(row)) : PRESET_SOURCE_TARGETS,
|
||||
};
|
||||
}
|
||||
|
||||
function listGovernanceDictionary(kind: RequirementGovernanceKind) {
|
||||
return api.get<GovernanceDictionaryRow[]>(`/governance/dictionaries?kind=${kind}`);
|
||||
}
|
||||
|
||||
function createGovernanceDictionary(kind: RequirementGovernanceKind, name: string, group?: string) {
|
||||
const { actorId, permissions } = getGovernanceActorContext();
|
||||
return api.post<GovernanceDictionaryRow>('/governance/dictionaries', { actorId, permissions, kind, name, group });
|
||||
}
|
||||
|
||||
function updateGovernanceDictionary(kind: RequirementGovernanceKind, id: string, name: string, group?: string) {
|
||||
const { actorId, permissions } = getGovernanceActorContext();
|
||||
return api.patch<GovernanceDictionaryRow>(`/governance/dictionaries/${kind}/${id}`, { actorId, permissions, kind, name, group });
|
||||
}
|
||||
|
||||
function deleteGovernanceDictionary(kind: RequirementGovernanceKind, id: string) {
|
||||
const { actorId, permissions } = getGovernanceActorContext();
|
||||
return api.deleteWithBody<GovernanceDictionaryRow>(`/governance/dictionaries/${kind}/${id}`, { actorId, permissions });
|
||||
}
|
||||
|
||||
function getGovernanceActorContext() {
|
||||
const user = useAuthStore.getState().user;
|
||||
const role = useMemberStore.getState().roles.find((item) => item.id === user?.roleId);
|
||||
return { actorId: user?.id ?? '', permissions: role?.permissions ?? [] };
|
||||
}
|
||||
|
||||
function governanceRowToDictItem(row: GovernanceDictionaryRow): DictItem {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: normalizeGovernanceCreatedAt(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function governanceRowToSourceTarget(row: GovernanceDictionaryRow, fallbackSourceType?: SourceType): SourceTarget {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sourceType: normalizeGovernanceSourceType(row.group, fallbackSourceType),
|
||||
createdAt: normalizeGovernanceCreatedAt(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGovernanceCreatedAt(value?: string) {
|
||||
return value ? value.slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeGovernanceSourceType(value?: string | null, fallback: SourceType = 'customer'): SourceType {
|
||||
return ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'].includes(value ?? '')
|
||||
? value as SourceType
|
||||
: fallback;
|
||||
}
|
||||
|
||||
@@ -9,17 +9,10 @@ import {
|
||||
updateTaskCategoryDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
let lastCategoriesFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<TaskCategory[] | null> {
|
||||
try {
|
||||
return await loadServerData<TaskCategory[]>('task-categories');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TaskCategoryState {
|
||||
categories: TaskCategory[];
|
||||
loaded: boolean;
|
||||
@@ -36,7 +29,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
|
||||
fetchCategories: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await listTaskCategoriesDomain().catch(loadStored);
|
||||
const cached = await listTaskCategoriesDomain().catch(() => null);
|
||||
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastCategoriesFetchAt = Date.now();
|
||||
set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true });
|
||||
@@ -57,15 +50,11 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createTaskCategoryDomain(item);
|
||||
set({
|
||||
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
const created = await createTaskCategoryDomain(item);
|
||||
set({
|
||||
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
@@ -80,11 +69,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
set({ categories: result.categories, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await createTaskCategoryDomain(result.category);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(result.categories);
|
||||
}
|
||||
await createTaskCategoryDomain(result.category);
|
||||
},
|
||||
expected: result.categories,
|
||||
getCurrent: () => get().categories,
|
||||
@@ -100,11 +85,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await updateTaskCategoryDomain(id, data);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
await updateTaskCategoryDomain(id, data);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
@@ -120,11 +101,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteTaskCategoryDomain(id);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
await deleteTaskCategoryDomain(id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
@@ -133,7 +110,3 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveTaskCategoriesFallback(categories: TaskCategory[]) {
|
||||
await saveServerData('task-categories', categories);
|
||||
}
|
||||
|
||||
@@ -7,17 +7,10 @@ import {
|
||||
listTaskWorklogsDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
let lastWorklogsFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<TaskWorklog[] | null> {
|
||||
try {
|
||||
return await loadServerData<TaskWorklog[]>('task-worklogs');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TaskWorklogState {
|
||||
worklogs: TaskWorklog[];
|
||||
loaded: boolean;
|
||||
@@ -33,7 +26,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
|
||||
fetchWorklogs: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await listTaskWorklogsDomain().catch(loadStored);
|
||||
const cached = await listTaskWorklogsDomain().catch(() => []);
|
||||
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastWorklogsFetchAt = Date.now();
|
||||
set({ worklogs: cached ?? [], loaded: true });
|
||||
@@ -50,15 +43,11 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
set({ worklogs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createTaskWorklogDomain(item);
|
||||
set({
|
||||
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveTaskWorklogsFallback(updated);
|
||||
}
|
||||
const created = await createTaskWorklogDomain(item);
|
||||
set({
|
||||
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().worklogs,
|
||||
@@ -72,11 +61,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
set({ worklogs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteTaskWorklogDomain(id);
|
||||
} catch {
|
||||
await saveTaskWorklogsFallback(updated);
|
||||
}
|
||||
await deleteTaskWorklogDomain(id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().worklogs,
|
||||
@@ -90,7 +75,3 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
.reduce((sum, w) => sum + w.hours, 0);
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveTaskWorklogsFallback(worklogs: TaskWorklog[]) {
|
||||
await saveServerData('task-worklogs', worklogs);
|
||||
}
|
||||
|
||||
@@ -11,29 +11,17 @@ import {
|
||||
updateTestCaseByVersionId,
|
||||
updateTestCaseStatusByVersionId,
|
||||
} from '@/lib/domain-api';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
import { createEntityId } from '@/lib/entity-id';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import type { WorkActivity } from '@/lib/work-activity';
|
||||
import {
|
||||
makeTestCaseCreatedActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: TestCase[]) {
|
||||
saveServerData('test-cases', items).catch(() => {});
|
||||
}
|
||||
|
||||
let lastTestCasesFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<TestCase[] | null> {
|
||||
try {
|
||||
return await loadServerData<TestCase[]>('test-cases');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TestCaseState {
|
||||
testCases: TestCase[];
|
||||
loaded: boolean;
|
||||
@@ -53,19 +41,19 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
loaded: false,
|
||||
|
||||
fetchTestCases: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = options?.versionId
|
||||
? await listTestCasesByVersionId(options.versionId).catch(loadStored)
|
||||
: await loadStored();
|
||||
if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
? await listTestCasesByVersionId(options.versionId).catch(() => [])
|
||||
: get().testCases;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastTestCasesFetchAt = Date.now();
|
||||
if (cached) {
|
||||
const result = dedupeEntityIds(normalizeTestCases(cached), 'tc');
|
||||
if (result.changed) saveStored(result.items);
|
||||
set({ testCases: result.items, loaded: true });
|
||||
} else {
|
||||
set({ loaded: true });
|
||||
}
|
||||
const normalized = normalizeTestCases(cached ?? []);
|
||||
set({
|
||||
testCases: options?.versionId
|
||||
? mergeTestCasesForVersion(get().testCases, options.versionId, normalized)
|
||||
: normalized,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createTestCase: (data) => {
|
||||
@@ -83,17 +71,12 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
set({ testCases: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const result = await createTestCaseByVersionId(tc.versionId, tc);
|
||||
set({
|
||||
testCases: get().testCases.map((item) => (item.id === tc.id ? { ...tc, ...result.item } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveTestCasesFallback(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
}
|
||||
const result = await createTestCaseByVersionId(tc.versionId, tc);
|
||||
set({
|
||||
testCases: get().testCases.map((item) => (item.id === tc.id ? { ...tc, ...result.item } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -123,25 +106,18 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
set({ testCases: list, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
if (!created[0]?.versionId) throw new Error('missing versionId');
|
||||
const result = await createTestCasesByVersionId(created[0].versionId, created);
|
||||
const byCaseNo = new Map(result.items.map((item) => [item.caseNo, item]));
|
||||
set({
|
||||
testCases: get().testCases.map((item) => {
|
||||
if (!created.some((tc) => tc.id === item.id)) return item;
|
||||
const replacement = byCaseNo.get(item.caseNo);
|
||||
return replacement ? { ...item, ...replacement } : item;
|
||||
}),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveTestCasesFallback(list);
|
||||
created.forEach((tc) => {
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
});
|
||||
}
|
||||
if (!created[0]?.versionId) throw new Error('missing versionId');
|
||||
const result = await createTestCasesByVersionId(created[0].versionId, created);
|
||||
const byCaseNo = new Map(result.items.map((item) => [item.caseNo, item]));
|
||||
set({
|
||||
testCases: get().testCases.map((item) => {
|
||||
if (!created.some((tc) => tc.id === item.id)) return item;
|
||||
const replacement = byCaseNo.get(item.caseNo);
|
||||
return replacement ? { ...item, ...replacement } : item;
|
||||
}),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: list,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -158,14 +134,10 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
set({ testCases: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateTestCaseByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveTestCasesFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateTestCaseByVersionId(versionId, id, data);
|
||||
appendDomainActivities(result.activities);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -179,13 +151,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
set({ testCases: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteTestCaseByVersionId(versionId, id);
|
||||
} catch {
|
||||
await saveTestCasesFallback(updated);
|
||||
}
|
||||
const versionId = previous.find((testCase) => testCase.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteTestCaseByVersionId(versionId, id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -211,14 +179,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
const activity = makeTestCaseStatusActivity(tc, tc.status, to, actorId);
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const result = await updateTestCaseStatusByVersionId(tc.versionId, id, to);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
} catch {
|
||||
await saveTestCasesFallback(updated);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
}
|
||||
const result = await updateTestCaseStatusByVersionId(tc.versionId, id, to);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().testCases,
|
||||
@@ -240,8 +203,11 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveTestCasesFallback(testCases: TestCase[]) {
|
||||
await saveServerData('test-cases', testCases);
|
||||
function mergeTestCasesForVersion(current: TestCase[], versionId: string, scopedTestCases: TestCase[]) {
|
||||
return [
|
||||
...current.filter((testCase) => testCase.versionId !== versionId),
|
||||
...scopedTestCases,
|
||||
];
|
||||
}
|
||||
|
||||
function appendDomainActivities(activities: WorkActivity[]) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||
import type { VersionPlan } from '@/lib/version-plan';
|
||||
import { mergeVersionPlanPatch } from '@/lib/version-plan';
|
||||
import {
|
||||
completeVersionPlanByVersionId,
|
||||
createVersionPlanByVersionId,
|
||||
@@ -9,12 +10,11 @@ import {
|
||||
updateVersionPlanByVersionId,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS, SERVER_DATA_SAVE_ERROR_EVENT } from '@/lib/server-data';
|
||||
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||
import {
|
||||
makeVersionPlanCompletedActivity,
|
||||
makeVersionPlanCreatedActivity,
|
||||
makeVersionPlanRequirementProgressActivity,
|
||||
makeVersionPlanResearchDirectionProgressActivity,
|
||||
makeVersionPlanStartedActivity,
|
||||
@@ -26,13 +26,6 @@ import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
const MOCK_PLANS: VersionPlan[] = [];
|
||||
let lastPlansFetchAt = 0;
|
||||
|
||||
async function loadStored(): Promise<VersionPlan[] | null> {
|
||||
try {
|
||||
return await loadServerData<VersionPlan[]>('version-plans');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VersionPlanState {
|
||||
plans: VersionPlan[];
|
||||
loaded: boolean;
|
||||
@@ -48,18 +41,31 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
loaded: false,
|
||||
|
||||
fetchPlans: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = options?.versionId
|
||||
? await listVersionPlansByVersionId(options.versionId).catch(loadStored)
|
||||
: await loadStored();
|
||||
if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
? await listVersionPlansByVersionId(options.versionId).catch(() => [])
|
||||
: get().plans;
|
||||
if (!options?.versionId && !options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastPlansFetchAt = Date.now();
|
||||
set({ plans: cached ?? MOCK_PLANS, loaded: true });
|
||||
set({
|
||||
plans: options?.versionId
|
||||
? mergeVersionPlansForVersion(get().plans, options.versionId, cached ?? MOCK_PLANS)
|
||||
: cached ?? MOCK_PLANS,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createPlan: (data) => {
|
||||
const previous = get().plans;
|
||||
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const now = new Date().toISOString();
|
||||
const autoStarted = shouldAutoStartCreatedPlan(data, now);
|
||||
const plan: VersionPlan = {
|
||||
...data,
|
||||
status: autoStarted ? 'in_progress' : data.status,
|
||||
actualStartAt: autoStarted ? data.actualStartAt ?? now : data.actualStartAt,
|
||||
id: `plan-${Date.now()}`,
|
||||
createdAt: now.slice(0, 10),
|
||||
};
|
||||
const plans = [...previous, plan];
|
||||
set({ plans, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
@@ -68,14 +74,14 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const result = await createVersionPlanByVersionId(plan.versionId, plan);
|
||||
set({
|
||||
plans: get().plans.map((item) => (
|
||||
item.id === plan.id ? { ...plan, ...result.item, tasks: plan.tasks } : item
|
||||
item.id === plan.id ? mergeCreatedVersionPlan(plan, item, result.item) : item
|
||||
)),
|
||||
loaded: true,
|
||||
});
|
||||
appendDomainActivities(result.activities);
|
||||
} catch {
|
||||
await saveVersionPlansFallback(plans);
|
||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
||||
} catch (error) {
|
||||
notifyVersionPlanSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: plans,
|
||||
@@ -88,9 +94,10 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const previous = get().plans;
|
||||
const now = new Date().toISOString();
|
||||
const activities: WorkActivityDraft[] = [];
|
||||
let apiPatch: Partial<VersionPlan> | null = null;
|
||||
const plans = previous.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const patch = { ...data };
|
||||
const patch = { ...mergeVersionPlanPatch(p, data) };
|
||||
if (patch.status === 'in_progress' && !p.actualStartAt) {
|
||||
(patch as any).actualStartAt = now;
|
||||
}
|
||||
@@ -112,6 +119,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const directionActivity = makeVersionPlanResearchDirectionProgressActivity(next, log);
|
||||
if (directionActivity) activities.push(directionActivity);
|
||||
}
|
||||
apiPatch = patch;
|
||||
return next;
|
||||
});
|
||||
set({ plans, loaded: true });
|
||||
@@ -120,14 +128,14 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
try {
|
||||
const versionId = previous.find((plan) => plan.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const result = await updateVersionPlanByVersionId(versionId, id, data);
|
||||
const result = await updateVersionPlanByVersionId(versionId, id, apiPatch ?? data);
|
||||
appendDomainActivities(result.activities);
|
||||
if (result.activities.length === 0) {
|
||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
||||
}
|
||||
} catch {
|
||||
await saveVersionPlansFallback(plans);
|
||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
||||
} catch (error) {
|
||||
notifyVersionPlanSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: plans,
|
||||
@@ -159,8 +167,9 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
const apiResult = await completeVersionPlanByVersionId(versionId, id, result);
|
||||
appendDomainActivities(apiResult.activities);
|
||||
} catch {
|
||||
await saveVersionPlansFallback(plans);
|
||||
} catch (error) {
|
||||
notifyVersionPlanSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: plans,
|
||||
@@ -180,8 +189,9 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const versionId = previous.find((plan) => plan.id === id)?.versionId;
|
||||
if (!versionId) throw new Error('missing versionId');
|
||||
await deleteVersionPlanByVersionId(versionId, id);
|
||||
} catch {
|
||||
await saveVersionPlansFallback(plans);
|
||||
} catch (error) {
|
||||
notifyVersionPlanSaveFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
expected: plans,
|
||||
@@ -191,10 +201,6 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveVersionPlansFallback(plans: VersionPlan[]) {
|
||||
await saveServerData('version-plans', plans);
|
||||
}
|
||||
|
||||
function appendDomainActivities(activities: WorkActivity[]) {
|
||||
if (activities.length === 0) return;
|
||||
useWorkActivityStore.setState((state) => ({
|
||||
@@ -202,3 +208,42 @@ function appendDomainActivities(activities: WorkActivity[]) {
|
||||
loaded: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeVersionPlansForVersion(current: VersionPlan[], versionId: string, scopedPlans: VersionPlan[]) {
|
||||
return [
|
||||
...current.filter((plan) => plan.versionId !== versionId),
|
||||
...scopedPlans,
|
||||
];
|
||||
}
|
||||
|
||||
function shouldAutoStartCreatedPlan(data: Omit<VersionPlan, 'id' | 'createdAt'>, nowIso: string): boolean {
|
||||
if (data.status !== 'pending' || !data.startTime || data.actualStartAt) return false;
|
||||
return new Date(data.startTime).getTime() <= new Date(nowIso).getTime();
|
||||
}
|
||||
|
||||
function mergeCreatedVersionPlan(initial: VersionPlan, current: VersionPlan, persisted: VersionPlan): VersionPlan {
|
||||
const localChanged = current.status !== initial.status
|
||||
|| current.actualStartAt !== initial.actualStartAt
|
||||
|| current.completedAt !== initial.completedAt;
|
||||
|
||||
return {
|
||||
...current,
|
||||
...persisted,
|
||||
status: localChanged ? current.status : persisted.status,
|
||||
actualStartAt: current.actualStartAt ?? persisted.actualStartAt,
|
||||
completedAt: current.completedAt ?? persisted.completedAt,
|
||||
tasks: current.tasks ?? initial.tasks ?? persisted.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
function notifyVersionPlanSaveFailure(error: unknown) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
|
||||
detail: {
|
||||
key: 'version-plans',
|
||||
message: error instanceof Error && error.message ? error.message : '版本计划保存失败,请检查 API 服务',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { formatLocalDate } from '@/lib/format';
|
||||
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
|
||||
import { type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
|
||||
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
|
||||
import {
|
||||
createWorkActivityDomain,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
listWorkActivitiesDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
interface WorkActivityState {
|
||||
activities: WorkActivity[];
|
||||
@@ -22,32 +22,6 @@ interface WorkActivityState {
|
||||
|
||||
let lastActivitiesFetchAt = 0;
|
||||
|
||||
async function saveStored(
|
||||
items: WorkActivity[],
|
||||
setActivities?: (items: WorkActivity[]) => void,
|
||||
options: { mergeRemote?: boolean } = {},
|
||||
) {
|
||||
try {
|
||||
if (options.mergeRemote === false) {
|
||||
await saveServerData('work-activities', items);
|
||||
return;
|
||||
}
|
||||
const remote = await loadServerData<WorkActivity[]>('work-activities', { force: true });
|
||||
const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items);
|
||||
setActivities?.(merged);
|
||||
await saveServerData('work-activities', merged);
|
||||
} catch {
|
||||
saveServerData('work-activities', items).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStored(): Promise<WorkActivity[] | null> {
|
||||
try {
|
||||
return await loadServerData<WorkActivity[]>('work-activities');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createActivityId(): string {
|
||||
return `act-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
@@ -58,7 +32,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
|
||||
fetchActivities: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await listWorkActivitiesDomain().catch(loadStored);
|
||||
const cached = await listWorkActivitiesDomain().catch(() => []);
|
||||
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastActivitiesFetchAt = Date.now();
|
||||
set({ activities: cached ?? [], loaded: true });
|
||||
@@ -77,15 +51,11 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
set({ activities: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createWorkActivityDomain(item);
|
||||
set({
|
||||
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveStored(updated, (activities) => set({ activities, loaded: true }));
|
||||
}
|
||||
const created = await createWorkActivityDomain(item);
|
||||
set({
|
||||
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().activities,
|
||||
@@ -104,11 +74,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
set({ activities: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteWorkActivityDomain(id);
|
||||
} catch {
|
||||
await saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
|
||||
}
|
||||
await deleteWorkActivityDomain(id);
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().activities,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
import {
|
||||
mergeDailySnapshotCacheForSave,
|
||||
mergeInsightCacheForSave,
|
||||
upsertDailySnapshot,
|
||||
upsertInsight,
|
||||
type XiaobaoRiskInsightCacheItem,
|
||||
@@ -26,24 +21,7 @@ interface XiaobaoRiskState {
|
||||
finishInsightUpdate: (key: string) => void;
|
||||
}
|
||||
|
||||
async function loadSnapshots(): Promise<XiaobaoRiskSnapshot[] | null> {
|
||||
try {
|
||||
const rows = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots');
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadInsights(): Promise<XiaobaoRiskInsightCacheItem[] | null> {
|
||||
try {
|
||||
const rows = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights');
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
let snapshotSaveQueue: Promise<void> = Promise.resolve();
|
||||
let insightSaveQueue: Promise<void> = Promise.resolve();
|
||||
const RISK_DATA_CACHE_MS = 30_000;
|
||||
let lastRiskDataFetchAt = 0;
|
||||
|
||||
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
@@ -55,54 +33,22 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
error: undefined,
|
||||
|
||||
fetchRiskData: async () => {
|
||||
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
set({ riskDataLoaded: false });
|
||||
const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]);
|
||||
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < RISK_DATA_CACHE_MS) return;
|
||||
lastRiskDataFetchAt = Date.now();
|
||||
set({
|
||||
...(snapshots ? { snapshots } : {}),
|
||||
...(insights ? { insights } : {}),
|
||||
riskDataLoaded: snapshots !== null && insights !== null,
|
||||
error: snapshots === null || insights === null ? '小宝预警缓存加载失败' : undefined,
|
||||
riskDataLoaded: true,
|
||||
error: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
saveSnapshot: async (item) => {
|
||||
const optimistic = upsertDailySnapshot(get().snapshots, item);
|
||||
set({ snapshots: optimistic, error: undefined });
|
||||
const task = snapshotSaveQueue.then(async () => {
|
||||
const remote = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots', { force: true });
|
||||
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||
set({ snapshots, error: undefined });
|
||||
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||
void notifyRiskManagers(item).catch(() => {});
|
||||
});
|
||||
snapshotSaveQueue = task.catch(() => undefined);
|
||||
try {
|
||||
await task;
|
||||
} catch (error) {
|
||||
set({ error: '小宝预警快照保存失败' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
saveInsight: async (item) => {
|
||||
const optimistic = upsertInsight(get().insights, item);
|
||||
set({ insights: optimistic, error: undefined });
|
||||
const task = insightSaveQueue.then(async () => {
|
||||
const remote = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights', { force: true });
|
||||
const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item);
|
||||
set({ insights, error: undefined });
|
||||
await saveServerData('xiaobao-risk-insights', insights);
|
||||
});
|
||||
insightSaveQueue = task.catch(() => undefined);
|
||||
try {
|
||||
await task;
|
||||
} catch (error) {
|
||||
set({ error: '小宝预警解读保存失败' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
beginInsightUpdate: (key) => {
|
||||
@@ -122,32 +68,3 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
||||
},
|
||||
}));
|
||||
|
||||
async function notifyRiskManagers(item: XiaobaoRiskSnapshot) {
|
||||
if (!['at_risk', 'likely_delayed', 'blocked'].includes(item.riskLevel)) return;
|
||||
const memberStore = useMemberStore.getState();
|
||||
if (!memberStore.loaded) {
|
||||
await memberStore.fetchMembers().catch(() => undefined);
|
||||
}
|
||||
const state = useMemberStore.getState();
|
||||
const roleMap = new Map(state.roles.map((role) => [role.id, role]));
|
||||
const recipients = state.members.filter((member) => {
|
||||
const permissions = roleMap.get(member.roleId)?.permissions ?? [];
|
||||
return permissions.includes('*') || permissions.includes('xiaobao.warning:manage');
|
||||
});
|
||||
await Promise.all(recipients.map((member) => api.post('/notifications', {
|
||||
recipientId: member.id,
|
||||
actorId: 'xiaobao',
|
||||
type: 'risk_alert',
|
||||
title: '小宝预警更新',
|
||||
body: `版本 ${item.versionId} 当前风险 ${item.riskLevel},风险分 ${item.riskScore}`,
|
||||
resourceType: 'version',
|
||||
resourceId: item.versionId,
|
||||
versionId: item.versionId,
|
||||
metadata: {
|
||||
riskLevel: item.riskLevel,
|
||||
riskScore: item.riskScore,
|
||||
riskSignature: `${item.versionId}:${item.date}:${item.riskScore}:${item.riskLevel}`,
|
||||
},
|
||||
}).catch(() => undefined)));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
markXiaobaoWarningRead,
|
||||
type XiaobaoWarningReadState,
|
||||
} from '@/lib/xiaobao-warning-view';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
interface XiaobaoWarningReadStoreState {
|
||||
readStates: XiaobaoWarningReadState[];
|
||||
@@ -16,15 +15,23 @@ interface XiaobaoWarningReadStoreState {
|
||||
markRiskRead: (userId: string, risk: XiaobaoVersionRisk, readAt?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let readStateSaveQueue: Promise<void> = Promise.resolve();
|
||||
const READ_STATE_CACHE_MS = 30_000;
|
||||
const READ_STATE_STORAGE_KEY = 'ftb_xiaobao_warning_views';
|
||||
let lastReadStatesFetchAt = 0;
|
||||
|
||||
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
|
||||
function loadReadStates(): XiaobaoWarningReadState[] {
|
||||
try {
|
||||
const rows = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views');
|
||||
return normalizeReadStates(rows);
|
||||
if (typeof window === 'undefined') return [];
|
||||
return normalizeReadStates(JSON.parse(window.localStorage.getItem(READ_STATE_STORAGE_KEY) || '[]'));
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveReadStates(rows: XiaobaoWarningReadState[]) {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(READ_STATE_STORAGE_KEY, JSON.stringify(rows));
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeReadStates(rows: unknown): XiaobaoWarningReadState[] {
|
||||
@@ -64,14 +71,14 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
|
||||
error: undefined,
|
||||
|
||||
fetchReadStates: async () => {
|
||||
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const rows = await loadReadStates();
|
||||
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < READ_STATE_CACHE_MS) return;
|
||||
const rows = loadReadStates();
|
||||
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < READ_STATE_CACHE_MS) return;
|
||||
lastReadStatesFetchAt = Date.now();
|
||||
set({
|
||||
readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates,
|
||||
readStateLoaded: rows !== null,
|
||||
error: rows === null ? '小宝预警查看状态加载失败' : undefined,
|
||||
readStates: mergeReadStates(rows, get().readStates),
|
||||
readStateLoaded: true,
|
||||
error: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -79,20 +86,6 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
|
||||
if (!userId) return;
|
||||
const optimistic = markXiaobaoWarningRead(get().readStates, userId, risk, readAt);
|
||||
set({ readStates: optimistic, error: undefined });
|
||||
|
||||
const task = readStateSaveQueue.then(async () => {
|
||||
const remote = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views', { force: true });
|
||||
const merged = mergeReadStates(normalizeReadStates(remote), get().readStates);
|
||||
set({ readStates: merged, readStateLoaded: true, error: undefined });
|
||||
await saveServerData('xiaobao-warning-views', merged);
|
||||
});
|
||||
readStateSaveQueue = task.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await task;
|
||||
} catch (error) {
|
||||
set({ error: '小宝预警查看状态保存失败' });
|
||||
throw error;
|
||||
}
|
||||
saveReadStates(optimistic);
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user