feat(ai-analysis): 添加详情页智能分析入口
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
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal file
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal file
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal file
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user