功能开发中,敬请期待
@@ -232,6 +367,16 @@ export default function VersionDetailPage() {
);
}
+function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) {
+ const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
+ return (
+
+ );
+}
+
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return (
diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx
index 994659c..0f9b56a 100644
--- a/apps/web/app/versions/page.tsx
+++ b/apps/web/app/versions/page.tsx
@@ -2,8 +2,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
-import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle } from 'lucide-react';
+import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2 } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status';
@@ -107,7 +108,7 @@ function getStageProgress(version: VersionWithContext): number {
export default function VersionsPage() {
const router = useRouter();
- const { overview, fetchOverview, createVersion, updateVersion } = useProductStore();
+ const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
@@ -146,8 +147,17 @@ export default function VersionsPage() {
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
- const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => {
+ const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
+ useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
+
+ const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => {
setOpenMenuId(null);
+ if (action === 'delete') {
+ if (!confirm('确认删除该版本?关联的需求会回到需求池。')) return;
+ requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
+ deleteVersion(version.productId, version.id);
+ return;
+ }
const statusMap: Record = {
pause: 'paused',
resume: 'developing',
@@ -242,9 +252,9 @@ export default function VersionsPage() {
) : (
<>
-
+
-
+
| 版本号 |
优先级 |
@@ -288,7 +298,7 @@ interface VersionRowProps {
openMenuId: string | null;
setOpenMenuId: (id: string | null) => void;
onNavigate: () => void;
- onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => void;
+ onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void;
}
function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
@@ -441,6 +451,15 @@ function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }
关闭
)}
+ {version.status === 'planned' && (
+
+ )}
>
)}
diff --git a/apps/web/components/AuthGuard.tsx b/apps/web/components/AuthGuard.tsx
new file mode 100644
index 0000000..5257d5d
--- /dev/null
+++ b/apps/web/components/AuthGuard.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { usePathname, useRouter } from 'next/navigation';
+import { useAuthStore } from '@/stores/useAuthStore';
+
+export function AuthGuard({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const { isAuthenticated, checkAuth } = useAuthStore();
+ const [checked, setChecked] = useState(false);
+
+ useEffect(() => {
+ checkAuth();
+ setChecked(true);
+ }, [checkAuth]);
+
+ useEffect(() => {
+ if (!checked) return;
+ if (!isAuthenticated && pathname !== '/login') {
+ router.replace('/login');
+ }
+ if (isAuthenticated && pathname === '/login') {
+ router.replace('/products');
+ }
+ }, [checked, isAuthenticated, pathname, router]);
+
+ if (!checked) return null;
+
+ if (!isAuthenticated && pathname !== '/login') return null;
+
+ return <>{children}>;
+}
diff --git a/apps/web/components/layout/LayoutShell.tsx b/apps/web/components/layout/LayoutShell.tsx
new file mode 100644
index 0000000..c0ab25c
--- /dev/null
+++ b/apps/web/components/layout/LayoutShell.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { Sidebar } from '@/components/layout/Sidebar';
+import { AuthGuard } from '@/components/AuthGuard';
+
+export function LayoutShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const isLoginPage = pathname === '/login';
+
+ return (
+
+ {isLoginPage ? (
+ <>{children}>
+ ) : (
+
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/requirement/RequirementModal.tsx b/apps/web/components/requirement/RequirementModal.tsx
index 0b5d807..9ba765f 100644
--- a/apps/web/components/requirement/RequirementModal.tsx
+++ b/apps/web/components/requirement/RequirementModal.tsx
@@ -10,6 +10,7 @@ interface RequirementModalProps {
initial?: Requirement | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
+ versions: { id: string; name: string; projectId: string }[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
@@ -36,6 +37,7 @@ export function RequirementModal({
initial,
products,
projects,
+ versions,
sourceTargets,
types,
platforms,
@@ -56,6 +58,7 @@ export function RequirementModal({
const [effort, setEffort] = useState('M');
const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState('');
+ const [versionId, setVersionId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false);
@@ -64,6 +67,11 @@ export function RequirementModal({
[projects, productId],
);
+ const filteredVersions = useMemo(
+ () => projectId ? versions.filter((v) => v.projectId === projectId) : [],
+ [versions, projectId],
+ );
+
useEffect(() => {
if (initial) {
setTitle(initial.title);
@@ -72,6 +80,7 @@ export function RequirementModal({
setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || '');
setProjectId(initial.projectId || '');
+ setVersionId(initial.versionId || '');
setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2');
@@ -85,6 +94,7 @@ export function RequirementModal({
setSourceTarget('');
setProductId('');
setProjectId('');
+ setVersionId('');
setSelectedPlatforms([]);
setTypeId('');
setPriority('P2');
@@ -113,6 +123,7 @@ export function RequirementModal({
sourceTarget: sourceTarget || undefined,
productId: productId || undefined,
projectId: projectId || undefined,
+ versionId: versionId || undefined,
platforms: selectedPlatforms,
typeId: typeId || undefined,
priority,
@@ -226,15 +237,15 @@ export function RequirementModal({
- {/* 4. 所属产品 → 项目 */}
-
+ {/* 4. 所属产品 → 项目 → 版本 */}
+
+
+
+ setVersionId(e.target.value)}
+ className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
+ >
+
+ {filteredVersions.map((v) => (
+
+ ))}
+
+
{/* 5. 支持端 (multi-select) */}
diff --git a/apps/web/components/version/PlanTab.tsx b/apps/web/components/version/PlanTab.tsx
new file mode 100644
index 0000000..26452d6
--- /dev/null
+++ b/apps/web/components/version/PlanTab.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
+import type { VersionPlan } from '@/lib/version-plan';
+import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan';
+
+interface Props {
+ plans: VersionPlan[];
+ versionId: string;
+ versionDeadline?: string;
+ currentUserName: string;
+ planType: 'research' | 'product' | 'ui';
+ linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
+ onCreate: (data: Omit
) => void;
+ onUpdate: (id: string, data: Partial) => void;
+ onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
+ onDelete: (id: string) => void;
+}
+
+const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
+const STATUS_STYLE = {
+ pending: 'bg-zinc-100 text-zinc-600',
+ in_progress: 'bg-blue-50 text-blue-600 border-blue-200',
+ completed: 'bg-green-50 text-green-700 border-green-200',
+};
+const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
+
+export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [editingPlan, setEditingPlan] = useState(null);
+ const [completingPlan, setCompletingPlan] = useState(null);
+
+ const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
+ const totalDuration = calcTotalDuration(typePlans);
+
+ return (
+
+
+
+ {typePlans.length} 个{TYPE_LABEL[planType]}计划
+ 总耗时:{totalDuration}
+ {versionDeadline && 版本截止:{versionDeadline}}
+
+
+
+
+ {typePlans.length === 0 ? (
+
+ 暂无{TYPE_LABEL[planType]}计划
+
+ ) : (
+
+ {typePlans.map((plan) => {
+ const dur = plan.completedAt
+ ? calcPlanDuration(plan.startTime, plan.completedAt)
+ : calcPlanDuration(plan.startTime, plan.endTime);
+ const durText = formatDuration(dur.days, dur.hours);
+ return (
+
+
+
+
+ {plan.title}
+
+ {STATUS_LABEL[plan.status]}
+
+
+
+ 负责人:{plan.owner}
+ 计划:{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)}
+ {plan.completedAt && 完成:{plan.completedAt.slice(0, 10)}}
+ 耗时 {durText}
+
+ {plan.overdueReason && (
+
超期原因:{plan.overdueReason}
+ )}
+ {plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
+
+ {plan.linkedRequirementIds.map((rid) => {
+ const req = linkedRequirements.find((r) => r.id === rid);
+ return req ? (
+
+ {req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''}
+ {req.productOwner && ({req.productOwner})}
+
+ ) : null;
+ })}
+
+ )}
+ {plan.status === 'completed' && plan.resultUrl && (
+
+ )}
+
+
+ {plan.status !== 'completed' && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {(showCreateModal || editingPlan) && (
+
{ setShowCreateModal(false); setEditingPlan(null); }}
+ onSubmit={(data) => {
+ if (editingPlan) onUpdate(editingPlan.id, data);
+ else onCreate(data as any);
+ setShowCreateModal(false);
+ setEditingPlan(null);
+ }}
+ />
+ )}
+
+ {completingPlan && (
+ setCompletingPlan(null)}
+ onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
+ />
+ )}
+
+ );
+}
+
+function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
+ initial: VersionPlan | null;
+ planType: 'research' | 'product' | 'ui';
+ versionId: string;
+ versionDeadline?: string;
+ currentUserName: string;
+ linkedRequirements?: { id: string; title: string; code: string }[];
+ onClose: () => void;
+ onSubmit: (data: any) => void;
+}) {
+ const now = new Date().toISOString().slice(0, 10);
+ const [title, setTitle] = useState(initial?.title ?? '');
+ const [owner] = useState(initial?.owner ?? currentUserName);
+ const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
+ const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
+ const [remark, setRemark] = useState(initial?.remark ?? '');
+ const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
+ const [selectedReqs, setSelectedReqs] = useState>(new Set(initial?.linkedRequirementIds ?? []));
+ const showReqSelect = planType === 'product' || planType === 'ui';
+ const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim() || !endTime) return;
+ if (isOverdue && !overdueReason.trim()) return;
+ onSubmit({
+ versionId,
+ type: planType,
+ title: title.trim(),
+ owner: owner.trim() || currentUserName,
+ startTime,
+ endTime,
+ status: initial?.status ?? 'pending',
+ linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
+ remark: remark.trim() || undefined,
+ overdueReason: isOverdue ? overdueReason.trim() : undefined,
+ addedBy: currentUserName,
+ });
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
{initial ? '编辑' : '新建'}{TYPE_LABEL[planType]}计划
+
+
+
+
+
+ );
+}
+
+function CompleteModal({ onClose, onSubmit }: {
+ onClose: () => void;
+ onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
+}) {
+ const [resultType, setResultType] = useState<'link' | 'file'>('link');
+ const [url, setUrl] = useState('');
+ const [fileName, setFileName] = useState('');
+ const [fileData, setFileData] = useState('');
+
+ const handleFile = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setFileName(file.name);
+ const reader = new FileReader();
+ reader.onload = () => setFileData(reader.result as string);
+ reader.readAsDataURL(file);
+ };
+
+ const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
+
+ return (
+
+
e.stopPropagation()}>
+
+
提交成果
+
+
+
+
+
+
+
+ {resultType === 'link' ? (
+
setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
+ ) : (
+
+
+ {fileName &&
已选:{fileName}
}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/version/VersionRequirementsTab.tsx b/apps/web/components/version/VersionRequirementsTab.tsx
new file mode 100644
index 0000000..b1041f1
--- /dev/null
+++ b/apps/web/components/version/VersionRequirementsTab.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, X, Search } from 'lucide-react';
+import type { Requirement } from '@/lib/requirement';
+import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
+
+interface Props {
+ versionId: string;
+ projectId: string;
+ requirements: Requirement[];
+ onLink: (reqIds: string[], addedBy: string) => void;
+ onUnlink: (reqId: string) => void;
+ currentUserName: string;
+}
+
+export function VersionRequirementsTab({ versionId, projectId, requirements, onLink, onUnlink, currentUserName }: Props) {
+ const [showAddModal, setShowAddModal] = useState(false);
+ const linkedReqs = requirements.filter((r) => r.versionId === versionId);
+ const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
+
+ return (
+
+
+
{linkedReqs.length} 条关联需求
+
+
+
+ {linkedReqs.length === 0 ? (
+
+ 暂无关联需求,从需求池中添加
+
+ ) : (
+
+
+
+
+ | 编号 |
+ 需求概述 |
+ 描述 |
+ 状态 |
+ 添加人 |
+ 操作 |
+
+
+
+ {linkedReqs.map((req) => (
+
+ | {req.code} |
+ {req.title} |
+ {req.description || '-'} |
+
+
+ {REQ_STATUS_LABEL[req.status]}
+
+ |
+ {req.addedToVersionBy || '-'} |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {showAddModal && (
+
setShowAddModal(false)}
+ onConfirm={(ids) => { onLink(ids, currentUserName); setShowAddModal(false); }}
+ />
+ )}
+
+ );
+}
+
+function AddRequirementModal({ available, onClose, onConfirm }: {
+ available: Requirement[];
+ onClose: () => void;
+ onConfirm: (ids: string[]) => void;
+}) {
+ const [selected, setSelected] = useState>(new Set());
+ const [search, setSearch] = useState('');
+
+ const filtered = available.filter((r) =>
+ r.title.includes(search) || r.code.includes(search)
+ );
+
+ const toggle = (id: string) => {
+ const next = new Set(selected);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ setSelected(next);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
从需求池添加
+
+
+
+
+
+ setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
+
+
+
+ {filtered.length === 0 ? (
+
没有可添加的需求
+ ) : (
+
+ {filtered.map((req) => (
+
+ ))}
+
+ )}
+
+
+
已选 {selected.size} 条
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/lib/overtime.ts b/apps/web/lib/overtime.ts
index 9956ebf..8cc200d 100644
--- a/apps/web/lib/overtime.ts
+++ b/apps/web/lib/overtime.ts
@@ -2,10 +2,11 @@ export interface OvertimeRecord {
id: string;
projectId: string;
versionId?: string;
+ requirementId?: string;
person: string;
startTime: string;
endTime: string;
- duration: number; // 小时,自动计算
+ duration: number;
reasonId: string;
remark?: string;
createdAt: string;
diff --git a/apps/web/lib/requirement.ts b/apps/web/lib/requirement.ts
index 2c68c23..99aafef 100644
--- a/apps/web/lib/requirement.ts
+++ b/apps/web/lib/requirement.ts
@@ -37,6 +37,7 @@ export interface Requirement {
creator: string;
createdAt: string;
parentId?: string;
+ addedToVersionBy?: string;
}
export const SOURCE_TYPE_LABEL: Record = {
diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts
new file mode 100644
index 0000000..e0f72c5
--- /dev/null
+++ b/apps/web/lib/version-plan.ts
@@ -0,0 +1,65 @@
+export interface VersionPlan {
+ id: string;
+ versionId: string;
+ type: 'research' | 'product' | 'ui';
+ title: string;
+ owner: string;
+ startTime: string;
+ endTime: string;
+ status: 'pending' | 'in_progress' | 'completed';
+ linkedRequirementIds?: string[];
+ resultType?: 'link' | 'file';
+ resultUrl?: string;
+ resultFileName?: string;
+ resultFileData?: string;
+ remark?: string;
+ overdueReason?: string;
+ createdAt: string;
+ completedAt?: string;
+ addedBy: string;
+}
+
+export type PlanType = VersionPlan['type'];
+
+export function calcPlanDuration(start: string, end: string): { days: number; hours: number } {
+ const s = new Date(start).getTime();
+ const e = new Date(end).getTime();
+ if (isNaN(s) || isNaN(e) || e <= s) return { days: 0, hours: 0 };
+ const totalDays = Math.ceil((e - s) / (1000 * 60 * 60 * 24));
+ return { days: totalDays, hours: totalDays * 8 };
+}
+
+export function formatDuration(days: number, _hours: number): string {
+ if (days === 0) return '0天';
+ return `${days}天`;
+}
+
+export function calcTotalDuration(plans: VersionPlan[]): string {
+ const completedOrActive = plans.filter((p) => p.status !== 'pending');
+ if (completedOrActive.length === 0) return '0天';
+
+ const intervals = completedOrActive.map((p) => ({
+ start: new Date(p.startTime).getTime(),
+ end: p.completedAt ? new Date(p.completedAt).getTime() : new Date(p.endTime).getTime(),
+ })).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
+
+ if (intervals.length === 0) return '0天';
+
+ let totalMs = 0;
+ let currentStart = intervals[0].start;
+ let currentEnd = intervals[0].end;
+
+ for (let i = 1; i < intervals.length; i++) {
+ if (intervals[i].start <= currentEnd) {
+ currentEnd = Math.max(currentEnd, intervals[i].end);
+ } else {
+ totalMs += currentEnd - currentStart;
+ currentStart = intervals[i].start;
+ currentEnd = intervals[i].end;
+ }
+ }
+ totalMs += currentEnd - currentStart;
+
+ const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
+ return `${totalDays}天`;
+}
diff --git a/apps/web/stores/useAuthStore.ts b/apps/web/stores/useAuthStore.ts
new file mode 100644
index 0000000..3820fb8
--- /dev/null
+++ b/apps/web/stores/useAuthStore.ts
@@ -0,0 +1,91 @@
+'use client';
+
+import { create } from 'zustand';
+
+interface AuthUser {
+ id: string;
+ name: string;
+ roleId: string;
+ departmentId: string;
+ phone: string;
+ email: string;
+}
+
+interface AuthState {
+ user: AuthUser | null;
+ isAuthenticated: boolean;
+ login: (phone: string, password: string, remember: boolean) => boolean;
+ logout: () => void;
+ checkAuth: () => void;
+}
+
+const SESSION_KEY = 'ftb_auth_session';
+const PERSIST_KEY = 'ftb_auth_persist';
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ isAuthenticated: false,
+
+ login: (phone, password, remember) => {
+ const membersRaw = localStorage.getItem('ftb_members_v1');
+ let members: any[] = [];
+ if (membersRaw) {
+ const parsed = JSON.parse(membersRaw);
+ members = parsed.members || [];
+ }
+
+ // fallback: 如果 localStorage 没有数据或为空,使用默认 mock
+ if (members.length === 0) {
+ members = [
+ { id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024' },
+ { id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024' },
+ { id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024' },
+ { id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024' },
+ { id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024' },
+ { id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024' },
+ { id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024' },
+ { id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024' },
+ ];
+ }
+
+ const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password);
+ if (!member) return false;
+
+ const user: AuthUser = {
+ id: member.id,
+ name: member.name,
+ roleId: member.roleId,
+ departmentId: member.departmentId,
+ phone: member.phone,
+ email: member.email,
+ };
+
+ set({ user, isAuthenticated: true });
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
+ if (remember) {
+ localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
+ }
+ return true;
+ },
+
+ logout: () => {
+ set({ user: null, isAuthenticated: false });
+ sessionStorage.removeItem(SESSION_KEY);
+ localStorage.removeItem(PERSIST_KEY);
+ },
+
+ checkAuth: () => {
+ const session = sessionStorage.getItem(SESSION_KEY);
+ if (session) {
+ set({ user: JSON.parse(session), isAuthenticated: true });
+ return;
+ }
+ const persist = localStorage.getItem(PERSIST_KEY);
+ if (persist) {
+ const user = JSON.parse(persist);
+ set({ user, isAuthenticated: true });
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
+ return;
+ }
+ },
+}));
diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts
index 66ede48..8e26bb1 100644
--- a/apps/web/stores/useProductStore.ts
+++ b/apps/web/stores/useProductStore.ts
@@ -40,43 +40,10 @@ const MOCK_OVERVIEW: ProductOverview[] = [
createdAt: '2024-01-01',
updatedAt: '2024-06-01',
projects: [
- { id: 'p1', name: '考勤', description: '员工打卡、排班管理', createdAt: '2024-01-10' },
- { id: 'p2', name: '电子合同', description: '入职、续签、离职合同电子化', createdAt: '2024-02-15' },
{ id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' },
],
- versions: [
- { id: 'v1', name: '考勤V1.0', status: 'released', releaseDate: '2024-02-01', createdAt: '2024-01-20', currentStage: 'released', startDate: '2024-01-20', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 5 }, { role: 'backend', percent: 100, daysSpent: 4 }, { role: 'testing', percent: 100, daysSpent: 2 }] },
- { id: 'v2', name: '考勤V1.2', status: 'developing', releaseDate: null, createdAt: '2024-04-15', currentStage: 'dev', startDate: '2024-04-15', expectedReleaseDate: '2024-06-10', priority: 'P1', links: { research: 'https://docs.example.com/kaoqin-v12', prototype: 'https://axure.example.com/kaoqin-v12' }, members: [{ role: 'product', name: '张三' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 5 }, { role: 'ui', percent: 100, daysSpent: 7 }, { role: 'frontend', percent: 60, daysSpent: 10 }, { role: 'backend', percent: 40, daysSpent: 12 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
- { id: 'v3', name: '考勤V1.3', status: 'planned', releaseDate: null, createdAt: '2024-06-01' },
- { id: 'v4', name: '电子合同V1.0', status: 'released', releaseDate: '2024-03-20', createdAt: '2024-03-01', currentStage: 'released', startDate: '2024-03-01', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'backend', percent: 100, daysSpent: 6 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
- { id: 'v5', name: '电子合同V2.0', status: 'released', releaseDate: '2024-06-01', createdAt: '2024-05-20', currentStage: 'released', startDate: '2024-05-01', members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 5 }, { role: 'ui', percent: 100, daysSpent: 6 }, { role: 'frontend', percent: 100, daysSpent: 12 }, { role: 'backend', percent: 100, daysSpent: 10 }, { role: 'testing', percent: 100, daysSpent: 4 }] },
- { id: 'v6', name: '电子合同V2.1', status: 'developing', releaseDate: null, createdAt: '2024-06-05', currentStage: 'ui_design', startDate: '2024-06-05', expectedReleaseDate: '2024-07-15', priority: 'P2', links: { research: 'https://docs.example.com/contract-v21' }, members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'ui', percent: 50, daysSpent: 4 }, { role: 'frontend', percent: 0, daysSpent: 0 }, { role: 'backend', percent: 0, daysSpent: 0 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
- { id: 'v7', name: '值班V1.0', status: 'released', releaseDate: '2024-04-01', createdAt: '2024-03-15', currentStage: 'released', startDate: '2024-03-15', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
- { id: 'v8', name: '值班V1.1', status: 'released', releaseDate: '2024-05-15', createdAt: '2024-05-01', currentStage: 'released', startDate: '2024-05-01', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 2 }, { role: 'frontend', percent: 100, daysSpent: 5 }, { role: 'backend', percent: 100, daysSpent: 4 }, { role: 'testing', percent: 100, daysSpent: 2 }] },
- { id: 'v9', name: '值班V1.2', status: 'developing', releaseDate: null, createdAt: '2024-06-02', currentStage: 'testing', startDate: '2024-06-02', expectedReleaseDate: '2024-06-20', priority: 'P0', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 2 }, { role: 'frontend', percent: 100, daysSpent: 6 }, { role: 'backend', percent: 100, daysSpent: 5 }, { role: 'testing', percent: 30, daysSpent: 2 }] },
- { id: 'v10', name: '值班V2.0', status: 'planned', releaseDate: null, createdAt: '2024-06-08' },
- ],
- _count: { requirements: 12, projects: 3, versions: 10 },
- },
- {
- id: 'mock-2',
- name: '智慧收银',
- description: '收银台 POS + 会员支付一体化',
- createdAt: '2024-03-01',
- updatedAt: '2024-06-01',
- projects: [
- { id: 'p4', name: '支付', description: '微信/支付宝/现金多渠道收银', createdAt: '2024-03-10' },
- { id: 'p5', name: '会员', description: '储值卡、积分、优惠券', createdAt: '2024-04-01' },
- ],
- versions: [
- { id: 'v11', name: '支付V1.0', status: 'released', releaseDate: '2024-04-20', createdAt: '2024-04-01', currentStage: 'released', startDate: '2024-04-01', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'backend', percent: 100, daysSpent: 7 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
- { id: 'v12', name: '支付V1.1', status: 'released', releaseDate: '2024-06-01', createdAt: '2024-05-25', currentStage: 'released', startDate: '2024-05-10', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 7 }, { role: 'backend', percent: 100, daysSpent: 6 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
- { id: 'v13', name: '支付V2.0', status: 'developing', releaseDate: null, createdAt: '2024-06-05', currentStage: 'dev', startDate: '2024-06-05', expectedReleaseDate: '2024-07-20', priority: 'P1', links: { research: 'https://docs.example.com/pay-v20', prototype: 'https://axure.example.com/pay-v20', ui: 'https://figma.com/pay-v20' }, members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'ui', percent: 100, daysSpent: 5 }, { role: 'frontend', percent: 30, daysSpent: 5 }, { role: 'backend', percent: 20, daysSpent: 4 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
- { id: 'v14', name: '会员V1.0', status: 'released', releaseDate: '2024-05-10', createdAt: '2024-04-20', currentStage: 'released', startDate: '2024-04-20', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 9 }, { role: 'backend', percent: 100, daysSpent: 7 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
- { id: 'v15', name: '会员V1.1', status: 'developing', releaseDate: null, createdAt: '2024-06-03', currentStage: 'integration', startDate: '2024-06-03', expectedReleaseDate: '2024-06-25', priority: 'P2', members: [{ role: 'product', name: '张三' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'ui', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 90, daysSpent: 8 }, { role: 'backend', percent: 85, daysSpent: 7 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
- { id: 'v16', name: '会员V2.0', status: 'planned', releaseDate: null, createdAt: '2024-06-08' },
- ],
- _count: { requirements: 8, projects: 2, versions: 6 },
+ versions: [],
+ _count: { requirements: 10, projects: 1, versions: 0 },
},
];
@@ -111,6 +78,7 @@ interface ProductState {
createProject: (productId: string, data: { name: string; description?: string }) => void;
createVersion: (productId: string, data: { name: string; status: string }) => void;
updateVersion: (productId: string, versionId: string, data: Partial) => void;
+ deleteVersion: (productId: string, versionId: string) => void;
}
export const useProductStore = create((set, get) => ({
@@ -259,12 +227,13 @@ export const useProductStore = create((set, get) => ({
},
createVersion: (productId, data) => {
- const newVersion = {
+ const newVersion: VersionItem = {
id: `ver-${Date.now()}`,
name: data.name,
status: data.status,
releaseDate: null,
createdAt: new Date().toISOString(),
+ ...(data as any),
};
const updated = get().overview.map((p) => {
if (p.id !== productId) return p;
@@ -291,9 +260,22 @@ export const useProductStore = create((set, get) => ({
set({ overview: updated });
saveLocal(updated);
},
+
+ deleteVersion: (productId, versionId) => {
+ const updated = get().overview.map((p) => {
+ if (p.id !== productId) return p;
+ return {
+ ...p,
+ versions: p.versions.filter((v) => v.id !== versionId),
+ _count: { ...p._count, versions: Math.max(0, (p._count?.versions ?? 0) - 1) } as any,
+ };
+ });
+ set({ overview: updated });
+ saveLocal(updated);
+ },
}));
-const STORAGE_KEY = 'ftb_products_overview_v2';
+const STORAGE_KEY = 'ftb_products_overview_v4';
function saveLocal(data: ProductOverview[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
diff --git a/apps/web/stores/useRequirementStore.ts b/apps/web/stores/useRequirementStore.ts
index 1135889..835bb4d 100644
--- a/apps/web/stores/useRequirementStore.ts
+++ b/apps/web/stores/useRequirementStore.ts
@@ -76,151 +76,21 @@ const PRESET_PLATFORMS: DictItem[] = [
// --- Mock requirements ---
const MOCK_REQUIREMENTS: Requirement[] = [
- {
- id: 'req-1',
- code: 'REQ-001',
- title: '考勤打卡支持人脸识别',
- description: '员工打卡增加人脸识别验证方式,防止代打卡',
- productId: 'mock-1',
- projectId: 'p1',
- versionId: 'v2',
- sourceType: 'customer',
- sourceTarget: '海底捞',
- platforms: ['platform-2', 'platform-3'],
- typeId: 'type-1',
- status: 'developing',
- priority: 'P1',
- effort: 'L',
- creator: '张三',
- createdAt: '2024-04-10',
- },
- {
- id: 'req-2',
- code: 'REQ-002',
- title: '电子合同批量签署',
- description: '支持企业批量发起合同签署,提升入职效率',
- productId: 'mock-1',
- projectId: 'p2',
- versionId: 'v13',
- sourceType: 'operation',
- sourceTarget: '运营部',
- platforms: ['platform-1'],
- typeId: 'type-1',
- status: 'pending_review',
- priority: 'P2',
- effort: 'XL',
- creator: '李四',
- createdAt: '2024-05-02',
- },
- {
- id: 'req-3',
- code: 'REQ-003',
- title: '支付渠道对账异常修复',
- description: '修复微信支付对账金额不一致的问题',
- productId: 'mock-2',
- projectId: 'p4',
- sourceType: 'customer',
- sourceTarget: '西贝',
- platforms: ['platform-1', 'platform-4'],
- typeId: 'type-2',
- status: 'testing',
- priority: 'P0',
- effort: 'M',
- creator: '王五',
- createdAt: '2024-05-15',
- },
- {
- id: 'req-4',
- code: 'REQ-004',
- title: '会员积分过期提醒',
- description: '积分到期前7天推送小程序模板消息通知',
- productId: 'mock-2',
- projectId: 'p5',
- versionId: 'v13',
- sourceType: 'management',
- sourceTarget: '张总',
- platforms: ['platform-4', 'platform-5'],
- typeId: 'type-4',
- status: 'planned',
- priority: 'P2',
- effort: 'S',
- creator: '张三',
- createdAt: '2024-05-20',
- },
- {
- id: 'req-5',
- code: 'REQ-005',
- title: '值班交接单增加拍照上传',
- description: '值班交接时支持拍照上传现场情况',
- productId: 'mock-1',
- projectId: 'p3',
- sourceType: 'internal',
- sourceTarget: '产品部',
- platforms: ['platform-2', 'platform-3'],
- typeId: 'type-1',
- status: 'adopted',
- priority: 'P3',
- effort: 'M',
- creator: '李四',
- createdAt: '2024-05-28',
- },
- {
- id: 'req-6',
- code: 'REQ-006',
- title: '考勤报表导出性能优化',
- description: '大数据量下考勤报表导出超时,需优化查询和导出逻辑',
- productId: 'mock-1',
- projectId: 'p1',
- versionId: 'v2',
- sourceType: 'customer',
- sourceTarget: '喜茶',
- platforms: ['platform-1'],
- typeId: 'type-7',
- status: 'developing',
- priority: 'P1',
- effort: 'M',
- creator: '王五',
- createdAt: '2024-06-01',
- },
- {
- id: 'req-7',
- code: 'REQ-007',
- title: '支付模块埋点需求',
- description: '支付成功率、失败原因分布等核心指标埋点',
- productId: 'mock-2',
- projectId: 'p4',
- sourceType: 'aftersale',
- sourceTarget: '客服部',
- platforms: ['platform-1', 'platform-4', 'platform-5'],
- typeId: 'type-3',
- status: 'released',
- priority: 'P2',
- effort: 'S',
- creator: '张三',
- createdAt: '2024-04-25',
- },
- {
- id: 'req-8',
- code: 'REQ-008',
- title: '会员模块重构-拆分储值与积分服务',
- description: '将储值卡和积分系统拆分为独立微服务,降低耦合度',
- productId: 'mock-2',
- projectId: 'p5',
- sourceType: 'competitor',
- sourceTarget: '美团收银',
- platforms: ['platform-1'],
- typeId: 'type-5',
- status: 'pending_review',
- priority: 'P3',
- effort: 'XL',
- creator: '李四',
- createdAt: '2024-06-05',
- },
+ { id: 'req-1', code: 'REQ-001', title: '值班排班自动生成', description: '根据员工可用时间自动排班,支持轮换规则', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
+ { id: 'req-2', code: 'REQ-002', title: '值班交接确认', description: '交接班时双方确认签到,记录交接内容', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'internal', sourceTarget: '门店经理', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
+ { id: 'req-3', code: 'REQ-003', title: '值班异常提醒', description: '迟到、缺勤自动推送提醒给店长', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P0', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌A', platforms: ['platform-1', 'platform-2'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-02' },
+ { id: 'req-4', code: 'REQ-004', title: '值班日历视图', description: '以日历形式展示当月值班安排', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '张三', createdAt: '2024-06-03' },
+ { id: 'req-5', code: 'REQ-005', title: '值班换班申请', description: '员工可发起换班申请,经理审批', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌B', platforms: ['platform-1'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-03' },
+ { id: 'req-6', code: 'REQ-006', title: '值班统计报表', description: '按月/周统计各员工值班时长、缺勤次数', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-3', creator: '张三', createdAt: '2024-06-04' },
+ { id: 'req-7', code: 'REQ-007', title: '多门店值班管理', description: '区域经理可查看管辖所有门店值班情况', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'XL', sourceType: 'management', sourceTarget: '区域总监', platforms: ['platform-1', 'platform-3'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-05' },
+ { id: 'req-8', code: 'REQ-008', title: '值班备注功能', description: '排班时可添加备注(如特殊事项提醒)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P3', effort: 'S', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '李四', createdAt: '2024-06-05' },
+ { id: 'req-9', code: 'REQ-009', title: '法定节假日排班规则', description: '节假日自动应用特殊排班规则(加班费标记)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'operation', sourceTarget: '合规部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
+ { id: 'req-10', code: 'REQ-010', title: '值班模板管理', description: '创建值班模板供快速复用(如早中晚三班)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
];
// --- localStorage helpers ---
-const STORAGE_KEY = 'ftb_requirements_v2';
+const STORAGE_KEY = 'ftb_requirements_v3';
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
try {
diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts
new file mode 100644
index 0000000..2f757c7
--- /dev/null
+++ b/apps/web/stores/useVersionPlanStore.ts
@@ -0,0 +1,62 @@
+'use client';
+import { create } from 'zustand';
+import type { VersionPlan, PlanType } from '@/lib/version-plan';
+
+const STORAGE_KEY = 'ftb_version_plans_v1';
+
+const MOCK_PLANS: VersionPlan[] = [];
+
+function saveLocal(plans: VersionPlan[]) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {}
+}
+
+function loadLocal(): VersionPlan[] | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch {}
+ return null;
+}
+
+interface VersionPlanState {
+ plans: VersionPlan[];
+ fetchPlans: () => void;
+ createPlan: (data: Omit) => void;
+ updatePlan: (id: string, data: Partial) => void;
+ completePlan: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
+ deletePlan: (id: string) => void;
+}
+
+export const useVersionPlanStore = create((set, get) => ({
+ plans: MOCK_PLANS,
+
+ fetchPlans: () => {
+ const cached = loadLocal();
+ if (cached) set({ plans: cached });
+ },
+
+ createPlan: (data) => {
+ const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
+ const plans = [...get().plans, plan];
+ set({ plans });
+ saveLocal(plans);
+ },
+
+ updatePlan: (id, data) => {
+ const plans = get().plans.map((p) => p.id === id ? { ...p, ...data } : p);
+ set({ plans });
+ saveLocal(plans);
+ },
+
+ completePlan: (id, result) => {
+ const plans = get().plans.map((p) => p.id === id ? { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() } : p);
+ set({ plans });
+ saveLocal(plans);
+ },
+
+ deletePlan: (id) => {
+ const plans = get().plans.filter((p) => p.id !== id);
+ set({ plans });
+ saveLocal(plans);
+ },
+}));