diff --git a/apps/web/lib/bug-workflow.test.ts b/apps/web/lib/bug-workflow.test.ts new file mode 100644 index 0000000..fc81cfe --- /dev/null +++ b/apps/web/lib/bug-workflow.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Bug } from './bug'; +import { applyBugTransition } from './bug-workflow'; + +function bug(patch: Partial = {}): Bug { + return { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'version-1', + testCaseId: 'tc-1', + title: '排序未保存', + description: '拖拽后刷新丢失', + severity: 'major', + priority: 'P1', + reportedBy: 'QA', + assigneeId: 'Dev', + status: 'open', + logs: [], + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + ...patch, + }; +} + +test('open to fixing returns a single bug status patch', () => { + const result = applyBugTransition(bug(), 'fixing', 'Dev', { + now: new Date('2026-06-25T01:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.status, 'fixing'); + assert.equal(result.patch?.logs?.length, 1); +}); + +test('fixing to fixed writes resolvedAt and resolution', () => { + const result = applyBugTransition(bug({ status: 'fixing' }), 'fixed', 'Dev', { + now: new Date('2026-06-25T02:00:00.000Z'), + resolution: '补充保存接口', + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.resolvedAt, '2026-06-25T02:00:00.000Z'); + assert.equal(result.patch?.resolution, '补充保存接口'); +}); + +test('verifying to closed writes closedAt', () => { + const result = applyBugTransition(bug({ status: 'verifying' }), 'closed', 'QA', { + now: new Date('2026-06-25T03:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.closedAt, '2026-06-25T03:00:00.000Z'); +}); + +test('invalid bug transition is rejected', () => { + const result = applyBugTransition(bug(), 'closed', 'QA'); + + assert.equal(result.ok, false); +}); diff --git a/apps/web/lib/bug-workflow.ts b/apps/web/lib/bug-workflow.ts new file mode 100644 index 0000000..63c87fc --- /dev/null +++ b/apps/web/lib/bug-workflow.ts @@ -0,0 +1,62 @@ +import type { Bug, BugLog, BugStatus } from './bug'; +import { canBugTransition } from './bug'; + +export interface BugTransitionOptions { + now?: Date; + resolution?: string; +} + +export interface BugWorkflowResult { + ok: boolean; + patch?: Partial; + message?: string; +} + +function makeBugLog( + action: BugLog['action'], + operator: string, + nowIso: 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: nowIso, + }; +} + +export function applyBugTransition( + bug: Bug, + to: BugStatus, + operator: string, + options: BugTransitionOptions = {}, +): BugWorkflowResult { + if (!canBugTransition(bug.status, to)) { + return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` }; + } + + const nowIso = (options.now ?? new Date()).toISOString(); + const patch: Partial = { status: to }; + + if (to === 'fixed') patch.resolvedAt = nowIso; + if (to === 'closed') patch.closedAt = nowIso; + if (options.resolution?.trim()) patch.resolution = options.resolution.trim(); + + const log = makeBugLog( + to === 'fixed' ? 'resolve' : 'status_change', + operator, + nowIso, + bug.status, + to, + options.resolution, + ); + patch.logs = [...(bug.logs || []), log]; + + return { ok: true, patch }; +} diff --git a/apps/web/stores/useBugStore.ts b/apps/web/stores/useBugStore.ts index 3e70173..b67d51b 100644 --- a/apps/web/stores/useBugStore.ts +++ b/apps/web/stores/useBugStore.ts @@ -1,18 +1,17 @@ 'use client'; import { create } from 'zustand'; import type { Bug, BugStatus, BugLog } from '@/lib/bug'; -import { canBugTransition, generateBugNo } from '@/lib/bug'; +import { generateBugNo } from '@/lib/bug'; +import { applyBugTransition } from '@/lib/bug-workflow'; +import { loadServerData, saveServerData } from '@/lib/server-data'; -const STORAGE_KEY = 'ftb_bugs_v1'; - -function saveLocal(items: Bug[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {} +function saveStored(items: Bug[]) { + saveServerData('bugs', items).catch(() => {}); } -function loadLocal(): Bug[] | null { +async function loadStored(): Promise { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('bugs'); } catch {} return null; } @@ -23,7 +22,7 @@ function makeLog(action: BugLog['action'], operator: string, from?: string, to?: interface BugState { bugs: Bug[]; - fetchBugs: () => void; + fetchBugs: () => Promise; createBug: (data: Omit, operator: string) => Bug; updateBug: (id: string, data: Partial) => void; deleteBug: (id: string) => void; @@ -37,8 +36,8 @@ interface BugState { export const useBugStore = create((set, get) => ({ bugs: [], - fetchBugs: () => { - const cached = loadLocal(); + fetchBugs: async () => { + const cached = await loadStored(); if (cached) set({ bugs: cached }); }, @@ -57,7 +56,7 @@ export const useBugStore = create((set, get) => ({ }; const updated = [...list, bug]; set({ bugs: updated }); - saveLocal(updated); + saveStored(updated); return bug; }, @@ -66,29 +65,24 @@ export const useBugStore = create((set, get) => ({ b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b, ); set({ bugs: updated }); - saveLocal(updated); + saveStored(updated); }, deleteBug: (id) => { const updated = get().bugs.filter((b) => b.id !== id); set({ bugs: updated }); - saveLocal(updated); + saveStored(updated); }, changeStatus: (id, to, operator, extra) => { const bug = get().bugs.find((b) => b.id === id); if (!bug) return { ok: false, message: 'Bug不存在' }; - if (!canBugTransition(bug.status, to)) { - return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` }; - } - const now = new Date().toISOString(); - const patch: Partial = { status: to }; - if (to === 'fixed') patch.resolvedAt = now; - if (to === 'closed') patch.closedAt = now; - if (extra?.resolution) patch.resolution = extra.resolution; - const log = makeLog(to === 'fixed' ? 'resolve' : 'status_change', operator, bug.status, to, extra?.resolution); - patch.logs = [...(bug.logs || []), log]; - get().updateBug(id, patch); + const result = applyBugTransition(bug, to, operator, { + now: new Date(), + resolution: extra?.resolution, + }); + if (!result.ok || !result.patch) return { ok: false, message: result.message }; + get().updateBug(id, result.patch); return { ok: true }; },