feat(ai-analysis): 添加详情页智能分析入口
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled

This commit is contained in:
2026-07-09 09:42:58 +08:00
parent 6d2999085e
commit 1e6fb0c7aa
6 changed files with 231 additions and 4 deletions

View File

@@ -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<any>(null);
const [editingProduct, setEditingProduct] = useState(false);
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(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}
</h1>
</div>
<div className="flex gap-1.5">
<div className="flex items-center gap-1.5">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
<button
onClick={() => setEditingProduct(true)}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
@@ -215,6 +227,14 @@ export default function ProductDetailPage() {
)}
</div>
</div>
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`产品智能分析 · ${currentProduct.name}`}
context={{ surface: 'product_detail', productId: currentProduct.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div>
);
}

View File

@@ -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<string>('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,9 +442,12 @@ export default function ProjectDetailPage() {
<span className="ml-2 text-[var(--ink-muted)]">/</span>
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span>
</div>
<div className="flex items-center gap-2">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
<Package className="h-3 w-3" /><span>{project.productName}</span>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
@@ -482,6 +492,13 @@ export default function ProjectDetailPage() {
</section>
</div>
</div>
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`项目智能分析 · ${project.name}`}
context={{ surface: 'project_detail', projectId: project.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div>
);
}

View File

@@ -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<VersionDataScope | null>(null);
@@ -432,7 +436,10 @@ export default function VersionDetailPage() {
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
</div>
<div className="flex items-center gap-2">{renderActions()}</div>
<div className="flex items-center gap-2">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
{renderActions()}
</div>
</header>
{/* Tab bar */}
@@ -1184,6 +1191,13 @@ export default function VersionDetailPage() {
onClose={() => setShowMemberModal(false)}
/>
)}
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`版本智能分析 · ${version.name}`}
context={{ surface: 'version_detail', versionId: version.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div>
);
}

View File

@@ -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<AnalysisRequest['context']>;
permissions: string[];
onClose: () => void;
}) {
const [question, setQuestion] = useState('');
const [response, setResponse] = useState<AnalysisResponse | null>(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<HTMLFormElement>) {
event.preventDefault();
void ask(question);
}
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
<aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/70 px-5">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-[#0f172a]">{title}</p>
<p className="mt-0.5 text-[11px] text-[#64748b]"></p>
</div>
<button
type="button"
onClick={onClose}
className="ml-3 rounded-full p-2 text-[#64748b] transition-colors hover:bg-white hover:text-[#0f172a]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<form
onSubmit={submit}
className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl"
>
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
rows={3}
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] leading-6 text-[#0f172a] outline-none placeholder:text-[#94a3b8]"
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
/>
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-[11px] text-[#64748b]"></span>
<button
type="submit"
disabled={loading || !question.trim()}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-[#0f172a] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#1e293b] disabled:cursor-not-allowed disabled:bg-[#cbd5e1]"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
{loading ? '分析中' : '开始分析'}
</button>
</div>
</form>
{response ? (
<AnalysisResultBlock response={response} onAsk={(prompt) => void ask(prompt)} />
) : (
<div className="rounded-[28px] border border-dashed border-[#dbe3ef] bg-white/55 p-6 text-[13px] leading-7 text-[#64748b]">
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { BarChart3, Sparkles } from 'lucide-react';
export function AnalysisEntryButton({
onClick,
compact = false,
}: {
onClick: () => void;
compact?: boolean;
}) {
const Icon = compact ? BarChart3 : Sparkles;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur transition-colors hover:bg-white"
title="智能分析"
>
<Icon className="h-3.5 w-3.5" />
</button>
);
}

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
function readSource(path: string): string {
assert.equal(existsSync(path), true, `${path} should exist`);
return readFileSync(path, 'utf8');
}
test('analysis context drawer is read-only and reuses the shared analysis result block', () => {
const entryButton = readSource('components/analysis/AnalysisEntryButton.tsx');
const drawer = readSource('components/analysis/AnalysisContextDrawer.tsx');
assert.match(entryButton, /export function AnalysisEntryButton/);
assert.match(entryButton, /智能分析/);
assert.match(drawer, /requestAnalysis/);
assert.match(drawer, /AnalysisResultBlock/);
assert.match(drawer, /只读取当前上下文和已有权限数据/);
assert.match(drawer, /permissions: string\[\]/);
assert.doesNotMatch(drawer, /create(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /update(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /delete(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
assert.doesNotMatch(drawer, /use(Product|Requirement|VersionPlan|DevTask|TestCase|Bug)Store/);
});
test('product, project, and version detail pages wire analysis drawer with scoped contexts', () => {
const productPage = readSource('app/products/[id]/page.tsx');
const projectPage = readSource('app/projects/[id]/page.tsx');
const versionPage = readSource('app/versions/[id]/page.tsx');
for (const source of [productPage, projectPage, versionPage]) {
assert.match(source, /AnalysisEntryButton/);
assert.match(source, /AnalysisContextDrawer/);
assert.match(source, /showAnalysisDrawer/);
assert.match(source, /currentPermissions/);
}
assert.match(productPage, /surface: 'product_detail'/);
assert.match(productPage, /productId: currentProduct\.id/);
assert.match(projectPage, /surface: 'project_detail'/);
assert.match(projectPage, /projectId: project\.id/);
assert.match(versionPage, /surface: 'version_detail'/);
assert.match(versionPage, /versionId: version\.id/);
});