diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index 5787593..86bd325 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -10,6 +10,10 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { RequirementTable } from '@/components/product/RequirementTable'; import { RequirementForm } from '@/components/product/RequirementForm'; import { ProductForm } from '@/components/product/ProductForm'; +import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer'; +import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; const TABS = [ { key: 'requirements' as const, label: '需求池' }, @@ -29,10 +33,13 @@ export default function ProductDetailPage() { deleteRequirement, } = useRequirementStore(); const { records, fetchRecords } = useOvertimeStore(); + const user = useAuthStore((state) => state.user); + const roles = useMemberStore((state) => state.roles); const [showReqForm, setShowReqForm] = useState(false); const [editingReq, setEditingReq] = useState(null); const [editingProduct, setEditingProduct] = useState(false); + const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false); const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements'); const [reqStatusFilter, setReqStatusFilter] = useState(null); @@ -47,6 +54,10 @@ export default function ProductDetailPage() { if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter); return list; }, [requirements, productId, reqStatusFilter]); + const currentPermissions = useMemo( + () => roles.find((role) => role.id === user?.roleId)?.permissions ?? [], + [roles, user?.roleId], + ); const handleFilterChange = (status: RequirementStatus | null) => { setReqStatusFilter(status); @@ -93,7 +104,8 @@ export default function ProductDetailPage() { {currentProduct.name} -
+
+ setShowAnalysisDrawer(true)} />
+ + setShowAnalysisDrawer(false)} + /> ); } diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index f668b21..8e2c2a5 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -19,6 +19,8 @@ import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel'; +import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer'; +import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton'; import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan'; import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview'; import { calcScopedVersionProgress } from '@/lib/version-progress'; @@ -327,6 +329,7 @@ export default function ProjectDetailPage() { const { testCases, fetchTestCases } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const [statusFilter, setStatusFilter] = useState('all'); + const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); @@ -340,6 +343,10 @@ export default function ProjectDetailPage() { const user = useAuthStore((s) => s.user); const currentUserName = user?.name || ''; const { roles } = useMemberStore(); + const currentPermissions = useMemo( + () => roles.find((role) => role.id === user?.roleId)?.permissions ?? [], + [roles, user?.roleId], + ); const isSuperAdmin = useMemo(() => { const r = roles.find((x) => x.id === user?.roleId); return !!r && r.permissions.includes('*'); @@ -435,8 +442,11 @@ export default function ProjectDetailPage() { / {project.name} -
- {project.productName} +
+ setShowAnalysisDrawer(true)} /> +
+ {project.productName} +
@@ -482,6 +492,13 @@ export default function ProjectDetailPage() {
+ setShowAnalysisDrawer(false)} + /> ); } diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 009ce43..41cd31c 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -17,6 +17,8 @@ import { PlanTab } from '@/components/version/PlanTab'; import { DevTaskTab } from '@/components/dev-task/DevTaskTab'; import { TestCaseTab } from '@/components/test-case/TestCaseTab'; import { BugTab } from '@/components/bug/BugTab'; +import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer'; +import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; @@ -135,6 +137,7 @@ export default function VersionDetailPage() { const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); + const currentPermissions = currentRole?.permissions ?? []; const memberCandidates = useMemo( () => allMembers.map((member) => ({ id: member.id, @@ -157,6 +160,7 @@ export default function VersionDetailPage() { const [showRecommendModal, setShowRecommendModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false); + const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false); const [v22Scope, setV22Scope] = useState(null); @@ -432,7 +436,10 @@ export default function VersionDetailPage() { / {version.name} -
{renderActions()}
+
+ setShowAnalysisDrawer(true)} /> + {renderActions()} +
{/* Tab bar */} @@ -1184,6 +1191,13 @@ export default function VersionDetailPage() { onClose={() => setShowMemberModal(false)} /> )} + setShowAnalysisDrawer(false)} + /> ); } diff --git a/apps/web/components/analysis/AnalysisContextDrawer.tsx b/apps/web/components/analysis/AnalysisContextDrawer.tsx new file mode 100644 index 0000000..0505fe6 --- /dev/null +++ b/apps/web/components/analysis/AnalysisContextDrawer.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { type FormEvent, useState } from 'react'; +import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared'; +import { Loader2, Send, X } from 'lucide-react'; +import { requestAnalysis } from '@/lib/analysis-api'; +import { AnalysisResultBlock } from './AnalysisResultBlock'; + +export function AnalysisContextDrawer({ + open, + title, + context, + permissions, + onClose, +}: { + open: boolean; + title: string; + context: NonNullable; + permissions: string[]; + onClose: () => void; +}) { + const [question, setQuestion] = useState(''); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + + if (!open) return null; + + async function ask(prompt: string) { + const value = prompt.trim(); + if (!value || loading) return; + + setLoading(true); + try { + const result = await requestAnalysis({ question: value, context }, permissions); + setResponse(result); + setQuestion(''); + } catch { + setResponse({ + ok: false, + code: 'AI_UNAVAILABLE', + message: '业务分析暂不可用,请稍后再试。', + }); + } finally { + setLoading(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void ask(question); + } + + return ( +
+