From 773527729d4e63922bc72aec90672aacaf8c56e5 Mon Sep 17 00:00:00 2001 From: XiaoLFeng Date: Wed, 23 Sep 2026 00:33:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=97=AE=E7=AD=94?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E8=B7=B3=E8=BF=87=E6=BC=8F=E7=BB=9F=E4=B8=8E?= =?UTF-8?q?=20Pages=20=E5=8C=BF=E5=90=8D=E7=AE=A1=E7=90=86=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: Pin 队列两条缺陷约束——Q-01/Q-02(问答进度)与 S-01/Q-03(Pages 展示态) 内容: - 问答: computeSessionProgress 补 skipped 桶,四桶之和恒等于 total; 进度条改多色段(已答 lagoon/跳过琥珀/已取消红),紧凑读数收敛为 「已答/总计」,明细移入 Hover/点击聚焦的 Tooltip 卡片 - 预览: Pages 展示态悬浮卡片按登录态隔离,未登录仅保留可渲染页面与 高级资源切换视图,隐藏版本列表、设为生效、Fork 与控制台跳转入口; 展开动效由 zoom-in-95 改为 slide-in-from-top-2 自上而下流动 影响: 仅 web 前端;SessionProgress 新增 skipped 字段,消费方均经 computeSessionProgress 构造,无手写字面量 --- .../interact/interact-flow.test.tsx | 30 ++- .../interact/session-progress-bar.test.tsx | 88 +++++++- .../interact/session-progress-bar.tsx | 114 ++++++---- .../interact/session-progress.test.ts | 26 ++- .../components/interact/session-progress.ts | 15 +- .../pages/showcase-shell.render.test.tsx | 82 +++++++- web/src/components/pages/showcase-shell.tsx | 198 +++++++++--------- 7 files changed, 391 insertions(+), 162 deletions(-) diff --git a/web/src/components/interact/interact-flow.test.tsx b/web/src/components/interact/interact-flow.test.tsx index d636142..a2433b0 100644 --- a/web/src/components/interact/interact-flow.test.tsx +++ b/web/src/components/interact/interact-flow.test.tsx @@ -61,20 +61,18 @@ describe('Interact flow integration & regression verification', () => { total: 3, answered: 1, cancelled: 1, + skipped: 0, remaining: 1, }) const { container } = render() - // 验证文字区包含已答、取消、未答 + // 验证紧凑读数保留「已答」,取消/未答明细收进详情浮层 expect(screen.getByText('已答')).toBeTruthy() - expect(screen.getByText('取消')).toBeTruthy() - expect(screen.getByText('未答')).toBeTruthy() - expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(3) - // 验证进度条分段颜色:已答含 bg-lagoon,取消含 bg-rose-400 + // 验证进度条分段颜色:已答含 bg-lagoon,取消含 bg-rose-500 const lagoonSegment = container.querySelector('.bg-lagoon') - const roseSegment = container.querySelector('.bg-rose-400') + const roseSegment = container.querySelector('.bg-rose-500') expect(lagoonSegment).not.toBeNull() expect(roseSegment).not.toBeNull() }) @@ -188,8 +186,12 @@ describe('Interact flow integration & regression verification', () => { ) } - const { rerender, container } = render() - const splitter1 = container.querySelector('[data-testid="splitter"]')! + const { rerender, container } = render( + , + ) + const splitter1 = container.querySelector( + '[data-testid="splitter"]', + )! vi.spyOn(splitter1, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, @@ -208,7 +210,9 @@ describe('Interact flow integration & regression verification', () => { ) // 用户拖动到 36% (360px) - const handle = container.querySelector('[data-testid="splitter-handle"]')! + const handle = container.querySelector( + '[data-testid="splitter-handle"]', + )! fireEvent.pointerDown(handle, { clientX: 450, clientY: 300 }) fireEvent.pointerMove(handle, { clientX: 360, clientY: 300 }) fireEvent.pointerUp(handle) @@ -222,7 +226,9 @@ describe('Interact flow integration & regression verification', () => { // 模拟回答完问题 1,下一个问题 2 弹出(重新挂载 Splitter) rerender() - const splitter2 = container.querySelector('[data-testid="splitter"]')! + const splitter2 = container.querySelector( + '[data-testid="splitter"]', + )! vi.spyOn(splitter2, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, @@ -242,7 +248,9 @@ describe('Interact flow integration & regression verification', () => { // 模拟新页面打开(重新执行 useState 初始化读取 Cookie) const secondVisit = render() - const splitter3 = secondVisit.container.querySelector('[data-testid="splitter"]')! + const splitter3 = secondVisit.container.querySelector( + '[data-testid="splitter"]', + )! vi.spyOn(splitter3, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, diff --git a/web/src/components/interact/session-progress-bar.test.tsx b/web/src/components/interact/session-progress-bar.test.tsx index 3a7c955..9aac179 100644 --- a/web/src/components/interact/session-progress-bar.test.tsx +++ b/web/src/components/interact/session-progress-bar.test.tsx @@ -1,28 +1,98 @@ /** @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { SessionProgressBar } from './session-progress-bar' +beforeAll(() => { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterEach(() => { + cleanup() +}) + describe('SessionProgressBar', () => { - it('shows answered, cancelled, remaining, and total with a segmented progressbar', () => { + it('renders a compact answered/total readout with a multi-segment progressbar', () => { render( , ) expect( - screen.getByLabelText('问题进度:已答 3,取消 1,未答 4,共 8'), + screen.getByLabelText('问题进度:已答 3,跳过 2,取消 1,未答 2,共 8'), ).toBeTruthy() expect(screen.getByText('已答')).toBeTruthy() - expect(screen.getByText('取消')).toBeTruthy() - expect(screen.getByText('未答')).toBeTruthy() - expect(screen.getByText('总计')).toBeTruthy() - expect(screen.getByText('3+1/8')).toBeTruthy() + expect(screen.getByText('3')).toBeTruthy() + expect(screen.getByText('8')).toBeTruthy() + // 紧凑读数不再直出取消/未答明细 + expect(screen.queryByText('取消')).toBeNull() + expect(screen.queryByText('未答')).toBeNull() const bar = screen.getByRole('progressbar') expect(bar.getAttribute('aria-valuenow')).toBe('3') expect(bar.getAttribute('aria-valuemax')).toBe('8') }) + + it('paints dedicated segments for answered, skipped, and cancelled', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.bg-lagoon')).not.toBeNull() + expect(container.querySelector('.bg-amber-400')).not.toBeNull() + expect(container.querySelector('.bg-rose-500')).not.toBeNull() + }) + + it('reveals the full breakdown card when the readout is focused', async () => { + render( + , + ) + + fireEvent.focus(screen.getByRole('button', { name: /问题进度/ })) + + // Radix Tooltip 会同步渲染一份 visually-hidden 的 role="tooltip" 镜像,故用 getAllByText + await waitFor(() => { + expect(screen.getAllByText('已回答').length).toBeGreaterThanOrEqual(1) + }) + expect(screen.getAllByText('未回答').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('已跳过').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('已取消').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('总计').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(2) + }) }) diff --git a/web/src/components/interact/session-progress-bar.tsx b/web/src/components/interact/session-progress-bar.tsx index a0460a1..90a085a 100644 --- a/web/src/components/interact/session-progress-bar.tsx +++ b/web/src/components/interact/session-progress-bar.tsx @@ -1,8 +1,23 @@ import type { SessionProgress } from './session-progress' import { - sessionProgressPercent, sessionProgressCancelledPercent, + sessionProgressPercent, + sessionProgressSkippedPercent, } from './session-progress' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@lumina/components/ui/tooltip' + +/** 详情浮层明细行:色点与进度条多色段一一对应 */ +const DETAIL_ROWS = [ + { key: 'answered', label: '已回答', dot: 'bg-lagoon' }, + { key: 'remaining', label: '未回答', dot: 'border border-white/30' }, + { key: 'skipped', label: '已跳过', dot: 'bg-amber-400' }, + { key: 'cancelled', label: '已取消', dot: 'bg-rose-500' }, +] as const export function SessionProgressBar({ progress, @@ -10,11 +25,13 @@ export function SessionProgressBar({ progress: SessionProgress }) { const answeredPercent = sessionProgressPercent(progress) + const skippedPercent = sessionProgressSkippedPercent(progress) const cancelledPercent = sessionProgressCancelledPercent(progress) - const label = `问题进度:已答 ${progress.answered},取消 ${progress.cancelled},未答 ${progress.remaining},共 ${progress.total}` + const label = `问题进度:已答 ${progress.answered},跳过 ${progress.skipped},取消 ${progress.cancelled},未答 ${progress.remaining},共 ${progress.total}` return ( -
+
+ {/* 多色段进度条:已答 lagoon / 已跳过琥珀 / 已取消红,未答保留底色空槽 */}
+
-
- - {progress.answered} - {progress.cancelled > 0 ? `+${progress.cancelled}` : ''}/ - {progress.total} - - - 已答{' '} - - {progress.answered} - - - · - - 取消{' '} - - {progress.cancelled} - - - · - - 未答{' '} - - {progress.remaining} - - - · - - 总计{' '} - {progress.total} - -
+ + {/* 紧凑读数「已答 / 总计」:Hover(桌面)或点击聚焦(移动端)弹出明细 Tooltip */} + + + + + + +

+ 问题进度 +

+
    + {DETAIL_ROWS.map((row) => ( +
  • + + + {row.label} + + + {progress[row.key]} + +
  • + ))} +
+
+ 总计 + + {progress.total} + +
+
+
+
) } diff --git a/web/src/components/interact/session-progress.test.ts b/web/src/components/interact/session-progress.test.ts index 6775a26..ea60f77 100644 --- a/web/src/components/interact/session-progress.test.ts +++ b/web/src/components/interact/session-progress.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest' import { computeSessionProgress, - sessionProgressPercent, sessionProgressCancelledPercent, + sessionProgressPercent, + sessionProgressSkippedPercent, } from './session-progress' import type { Question } from './types' @@ -22,7 +23,7 @@ function q( } describe('computeSessionProgress', () => { - it('counts answered, pending, cancelled, and total', () => { + it('counts answered, pending, skipped, cancelled, and total', () => { expect( computeSessionProgress([ q({ id: 'a', status: 'answered' }), @@ -31,7 +32,7 @@ describe('computeSessionProgress', () => { q({ id: 'd', status: 'skipped', answered: false }), q({ id: 'e', status: 'cancelled', answered: false }), ]), - ).toEqual({ total: 5, answered: 2, cancelled: 1, remaining: 1 }) + ).toEqual({ total: 5, answered: 2, cancelled: 1, skipped: 1, remaining: 1 }) }) it('returns zeros for an empty list', () => { @@ -39,6 +40,7 @@ describe('computeSessionProgress', () => { total: 0, answered: 0, cancelled: 0, + skipped: 0, remaining: 0, }) }) @@ -51,6 +53,7 @@ describe('sessionProgressPercent', () => { total: 3, answered: 1, cancelled: 0, + skipped: 0, remaining: 2, }), ).toBe(33) @@ -59,6 +62,7 @@ describe('sessionProgressPercent', () => { total: 4, answered: 2, cancelled: 1, + skipped: 0, remaining: 1, }), ).toBe(50) @@ -70,6 +74,7 @@ describe('sessionProgressPercent', () => { total: 0, answered: 0, cancelled: 0, + skipped: 0, remaining: 0, }), ).toBe(0) @@ -83,6 +88,21 @@ describe('sessionProgressCancelledPercent', () => { total: 4, answered: 1, cancelled: 1, + skipped: 0, + remaining: 2, + }), + ).toBe(25) + }) +}) + +describe('sessionProgressSkippedPercent', () => { + it('rounds skipped over total', () => { + expect( + sessionProgressSkippedPercent({ + total: 8, + answered: 3, + cancelled: 1, + skipped: 2, remaining: 2, }), ).toBe(25) diff --git a/web/src/components/interact/session-progress.ts b/web/src/components/interact/session-progress.ts index 210b248..cbf9450 100644 --- a/web/src/components/interact/session-progress.ts +++ b/web/src/components/interact/session-progress.ts @@ -4,19 +4,23 @@ export interface SessionProgress { total: number answered: number cancelled: number + skipped: number remaining: number } export function computeSessionProgress(questions: Question[]): SessionProgress { + // 四桶之和恒等于 total:pending→remaining,cancelled/skipped/answered 各归各桶 let answered = 0 let cancelled = 0 + let skipped = 0 let remaining = 0 for (const q of questions) { if (q.status === 'pending') remaining += 1 else if (q.status === 'cancelled') cancelled += 1 - else if (q.status === 'answered' || q.answered) answered += 1 + else if (q.status === 'skipped') skipped += 1 + else answered += 1 } - return { total: questions.length, answered, cancelled, remaining } + return { total: questions.length, answered, cancelled, skipped, remaining } } export function sessionProgressPercent(progress: SessionProgress): number { @@ -30,3 +34,10 @@ export function sessionProgressCancelledPercent( if (progress.total <= 0) return 0 return Math.round((progress.cancelled / progress.total) * 100) } + +export function sessionProgressSkippedPercent( + progress: SessionProgress, +): number { + if (progress.total <= 0) return 0 + return Math.round((progress.skipped / progress.total) * 100) +} diff --git a/web/src/components/pages/showcase-shell.render.test.tsx b/web/src/components/pages/showcase-shell.render.test.tsx index 1bc8ede..cc71a8c 100644 --- a/web/src/components/pages/showcase-shell.render.test.tsx +++ b/web/src/components/pages/showcase-shell.render.test.tsx @@ -1,9 +1,15 @@ /** @vitest-environment jsdom */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ShowcaseShell } from './showcase-shell' +const authState = { isAuthenticated: false } + +vi.mock('#/hooks/useAuth', () => ({ + useAuth: () => ({ isAuthenticated: authState.isAuthenticated }), +})) + vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), })) @@ -19,6 +25,10 @@ vi.mock('#/components/preview/file-viewer', () => ({ ), })) +beforeEach(() => { + authState.isAuthenticated = false +}) + afterEach(() => { cleanup() }) @@ -37,6 +47,13 @@ describe('ShowcaseShell UI', () => { created_at: '2026-09-16T12:00:00Z', } + const mockInactiveVersion = { + ...mockVersion, + id: 'ver_2', + version: 'v1.1.0', + is_active: false, + } + const mockPage = { id: 'page_1', project_id: 'proj_1', @@ -74,8 +91,9 @@ describe('ShowcaseShell UI', () => { }, ] - it('renders top Header and floating Bar with capsule header', () => { - render( + function renderShell(isAuthenticated: boolean, versions = [mockVersion]) { + authState.isAuthenticated = isAuthenticated + return render( { page={mockPage} version={mockVersion} files={mockFiles} - versions={[mockVersion]} + versions={versions} />, ) + } + + it('renders top Header and floating Bar with capsule header', () => { + renderShell(true) // 顶部 Header expect(screen.getByText('Lumina')).toBeTruthy() @@ -122,4 +144,56 @@ describe('ShowcaseShell UI', () => { fireEvent.pointerDown(document.body) expect(screen.queryByText('元信息')).toBeNull() }) + + it('S-01: hides meta, version list, fork and console entries from anonymous visitors', () => { + renderShell(false) + + fireEvent.click( + screen.getByRole('button', { name: '展开或折叠页面管理卡片' }), + ) + + // 匿名访客仅保留「可渲染页面」与「高级资源」文件切换视图 + expect(screen.getByText('可渲染页面')).toBeTruthy() + expect(screen.getAllByText('index.html').length).toBeGreaterThan(0) + fireEvent.click(screen.getByText('高级资源')) + expect(screen.getByText('style.css')).toBeTruthy() + + // 管理入口彻底隐藏 + expect(screen.queryByText('元信息')).toBeNull() + expect(screen.queryByText('版本')).toBeNull() + expect(screen.queryByText('当前生效: v1.0.0')).toBeNull() + expect(screen.queryByText('设为生效')).toBeNull() + expect(screen.queryByText('复制当前路径')).toBeNull() + expect(screen.queryByText('Fork 到新预览')).toBeNull() + expect(screen.queryByText('管理员端口 · 页面安全设置')).toBeNull() + }) + + it('S-01: keeps version switching available for authenticated visitors', () => { + renderShell(true, [mockVersion, mockInactiveVersion]) + + fireEvent.click( + screen.getByRole('button', { name: '展开或折叠页面管理卡片' }), + ) + + expect(screen.getByText('元信息')).toBeTruthy() + expect(screen.getByText('版本')).toBeTruthy() + expect(screen.getByText('v1.1.0')).toBeTruthy() + expect(screen.getByText('设为生效')).toBeTruthy() + expect(screen.getByText('Fork 到新预览')).toBeTruthy() + expect(screen.getByText('管理员端口 · 页面安全设置')).toBeTruthy() + }) + + it('Q-03: expands the floating card with a top-down slide instead of zoom', () => { + renderShell(true) + + fireEvent.click( + screen.getByRole('button', { name: '展开或折叠页面管理卡片' }), + ) + + const card = screen.getByText('可渲染页面').closest('div.animate-in') + expect(card).not.toBeNull() + expect(card?.classList.contains('slide-in-from-top-2')).toBe(true) + expect(card?.classList.contains('fade-in')).toBe(true) + expect(card?.classList.contains('zoom-in-95')).toBe(false) + }) }) diff --git a/web/src/components/pages/showcase-shell.tsx b/web/src/components/pages/showcase-shell.tsx index e383675..0509b93 100644 --- a/web/src/components/pages/showcase-shell.tsx +++ b/web/src/components/pages/showcase-shell.tsx @@ -4,6 +4,7 @@ import { Copy, GitBranch, GripVertical, Settings2 } from 'lucide-react' import { toast } from 'sonner' import { PagesBrandHeader } from '#/components/pages/brand-header' import { PreviewFileViewer } from '#/components/preview/file-viewer' +import { useAuth } from '#/hooks/useAuth' import { previewKindFromFilename } from '#/lib/preview-file' import { useForkPage, useSwitchActiveVersion } from '#/hooks/usePages' import type { @@ -62,6 +63,7 @@ export function ShowcaseShell({ versions: PageVersionItem[] }) { const navigate = useNavigate() + const { isAuthenticated } = useAuth() const [open, setOpen] = useState(false) const [advanced, setAdvanced] = useState(false) const [pos, setPos] = useState({ x: 20, y: 16 }) @@ -233,72 +235,76 @@ export function ShowcaseShell({
- {/* 展开悬浮面板卡片(挂在 Bar 容器内部,跟随 Bar 悬浮展开) */} + {/* 展开悬浮面板卡片(挂在 Bar 容器内部,跟随 Bar 悬浮展开,自上而下流动) */} {open ? ( -
- {/* 元信息区 */} -
-

- 元信息 -

-

- {page.title} -

-

- {page.access_mode === 'password' - ? '🔒 密码保护' - : '🌐 公开访问'}{' '} - · 不可变快照 -

-
- - {/* 生效指针与版本历史 */} -
-
+
+ {/* 元信息区(登录态可见;匿名访客只保留文件切换视图) */} + {isAuthenticated ? ( +

- 版本 + 元信息

- - 当前生效: {version.version} - -
-
    - {versions.map((item) => ( -
  • - + {page.title} +

    +

    + {page.access_mode === 'password' + ? '🔒 密码保护' + : '🌐 公开访问'}{' '} + · 不可变快照 +

    +
+ ) : null} + + {/* 生效指针与版本历史(管理入口,仅登录态可见) */} + {isAuthenticated ? ( +
+
+

+ 版本 +

+ + 当前生效: {version.version} + +
+
    + {versions.map((item) => ( +
  • - {item.version} - - {!item.is_active ? ( - - ) : ( - - 当前 + {item.version} - )} -
  • - ))} -
-
+ {!item.is_active ? ( + + ) : ( + + 当前 + + )} + + ))} + + + ) : null} {/* 可渲染页面直切区 */}
@@ -356,43 +362,45 @@ export function ShowcaseShell({
) : null} - {/* 底部快捷操作条 */} -
- - + - - 管理员端口 · 页面安全设置 - -
+ ) + } + > + Fork + 到新预览 + + + 管理员端口 · 页面安全设置 + +
+ ) : null}
) : null}
From 78fa5e8bcd0360a29079697a94c970d3629feed1 Mon Sep 17 00:00:00 2001 From: XiaoLFeng Date: Wed, 23 Sep 2026 01:21:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(=E7=BA=A6=E6=9D=9F):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AA=E8=AF=BB=E9=A2=84=E8=A7=88=E5=B9=B6=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E5=86=B3=E7=AD=96=E4=B8=8E=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: 原 pin_consume 读取即原子出队并标记已消费,导致 Agent 无法在不改变 pending 状态的前提下审阅队首正文并进行技术决策 内容: - 持久层: PinRepo 新增 GetOldestPending 纯只读查询队首 pending 记录 - 业务层: PinLogic 新增 PeekOldestPending 供消费前审阅且无副作用 - 工具层: pin_peek 支持 project_name 参数以只读预览队首待处理约束;pin_list 补齐正文展示;pin_consume 明确为决策处理完毕后的显式消费闭环 - 测试层: 补齐 Logic 与 MCP 端到端全链路生命周期测试用例 影响: 仅扩展 Pin 模块读取能力,不破坏现有精确 ID 消费与 FIFO 原子出队契约 --- .agents/skills/lumina-pin/SKILL.md | 76 ++-- .../lumina-pin/examples/push-and-consume.md | 46 ++- .../skills/lumina-pin/references/fields.md | 6 +- internal/logic/pin.go | 15 + internal/logic/pin_lifecycle_test.go | 361 ++++++++++++++++++ internal/mcp/pin_tools.go | 64 +++- internal/mcp/pin_tools_test.go | 289 ++++++++++++++ internal/mcp/server.go | 2 +- internal/repository/pin.go | 19 + 9 files changed, 810 insertions(+), 68 deletions(-) create mode 100644 internal/logic/pin_lifecycle_test.go diff --git a/.agents/skills/lumina-pin/SKILL.md b/.agents/skills/lumina-pin/SKILL.md index 8f05268..85dde8f 100644 --- a/.agents/skills/lumina-pin/SKILL.md +++ b/.agents/skills/lumina-pin/SKILL.md @@ -21,7 +21,7 @@ allowed-tools: Read, Write, Edit, Bash, AskUserQuestion, mcp__lumina__project_ge ## 🎯 核心定位与适用场景 - **约束发布方 (Producer)**:你在开发项目 A 时,修改了公共模块或 API 契约,这将直接影响项目 B、C。此时通过 `pin_push` 将约束推送到目标项目。 -- **约束消费方 (Consumer)**:你在进入项目 B 开发时,通过 `pin_list` 和 `pin_consume` 按创建时间升序依序消费未处理的约束,确认适配并闭环。 +- **约束消费方 (Consumer)**:你在进入项目 B 开发时,先通过 `pin_peek` 或 `pin_list` 只读读取待处理约束正文,评估影响并做出技术决策;完成代码适配或确认知晓后,再调用 `pin_consume` 显式确认消费并闭环。 --- @@ -33,11 +33,11 @@ allowed-tools: Read, Write, Edit, Bash, AskUserQuestion, mcp__lumina__project_ge pin_push(to="B", priority, content) │ │ │ ▼ (落库进入 B 的 FIFO 队列) │ - [Pending] ──────────────────────────────▶ pin_list(project="B", status="pending") - │ + [Pending] ──────────────────────────────▶ pin_peek(project="B") 或 pin_list + │ (只读读取正文,状态仍为 Pending,开展本地决策与适配) ▼ - pin_consume(project="B") - │ + pin_consume(project="B", id="...") + │ (代码适配完成,显式确认消费闭环) ▼ [Consumed] (状态单向归档) ``` @@ -63,8 +63,19 @@ pin_push(to="B", priority, content) │ --- -### 2. 查阅项目约束队列 (`pin_list`) -进入目标项目工作区后,首先查看当前项目有哪些待处理约束: +### 2. 只读读取约束详情与决策评估 (`pin_peek` / `pin_list`) —— 只读不改状态 +进入目标项目后,首先读取待处理约束并做出技术决策,**绝不改变任何状态(保持 pending)**: + +#### 模式 A:只读预览队首待处理约束 (`pin_peek`) +传入 `project_name`(不传 `id`),直接只读获取队首待处理约束的完整标题与 Markdown 正文: +```json +{ + "project_name": "Lumina-Frontend" +} +``` + +#### 模式 B:全览待处理列表及内容 (`pin_list`) +查看当前项目积压的待处理约束列表(每条均包含标题、正文、分类与优先级): ```json { "project_name": "Lumina-Frontend", @@ -73,51 +84,52 @@ pin_push(to="B", priority, content) │ "size": 10 } ``` -- 结果按 `createdAt` 升序排列(天然呈现 FIFO 顺序)。 + +#### 模式 C:精确查看指定 ID 约束 (`pin_peek`) +```json +{ + "id": "1234567890123456789" +} +``` --- -### 3. 消费与闭环约束 (`pin_consume`) -确认已在代码中适配或处理了该约束后,将其标记为已消费: +### 3. 本地代码适配与显式消费闭环 (`pin_consume`) +在本地审阅正文、完成决策并落实代码修改(或确认知晓)后,调用 `pin_consume` 显式将约束状态标记为已消费: -#### 模式 A:FIFO 队首消费(最常用) -不传 `id`,自动取出并消费最旧的一条 pending 约束: +#### 模式 A:精确 ID 消费(推荐) +针对性消费刚刚已处理完毕的特定约束: ```json { - "project_name": "Lumina-Frontend" + "project_name": "Lumina-Frontend", + "id": "1234567890123456789" } ``` -#### 模式 B:精确 ID 消费 -当针对性处理了某一条特定约束时,传入其雪花 ID: +#### 模式 B:FIFO 队首消费 +不传 `id`,消费当前队首约束: ```json { - "project_name": "Lumina-Frontend", - "id": "1234567890123456789" + "project_name": "Lumina-Frontend" } ``` --- -### 4. 只读回查与元数据调整 - -- **只读预览 (`pin_peek`)**:查看指定 Pin 的完整内容(不改变 pending / consumed 状态): - ```json - {"id": "1234567890123456789"} - ``` -- **调整元数据 (`pin_update`)**:调整约束的优先级或分类(注意:状态不能通过此工具修改): - ```json - { - "id": "1234567890123456789", - "priority": "medium", - "category": "notice" - } - ``` +### 4. 约束元数据调整 (`pin_update`) +调整约束的优先级或分类(状态只能通过 `pin_consume` 推进到 `consumed`): +```json +{ + "id": "1234567890123456789", + "priority": "medium", + "category": "notice" +} +``` --- ## ⛔ 核心红线 (Hard Rules) 1. **MUST**: 状态流转单向原子化。`pending` 转换为 `consumed` 只能通过 `pin_consume`,严禁通过任何其他方式绕过。 -2. **MUST**: 消费前核对内容。消费代表“该约束已在当前项目中得到处理/知晓”,不可在未做任何处理时盲目批量清空队列。 +2. **MUST**: 读取与消费分离。读取操作(`pin_peek` / `pin_list`)绝不修改状态,Agent 必须先读取正文、评估决策并完成本地处理,随后显式调用 `pin_consume` 确认闭环。 3. **NEVER**: 严禁将本地临时待办当作跨项目约束。Pin 专用于**跨仓库/跨项目**的契约传递。 diff --git a/.agents/skills/lumina-pin/examples/push-and-consume.md b/.agents/skills/lumina-pin/examples/push-and-consume.md index 3734897..3106460 100644 --- a/.agents/skills/lumina-pin/examples/push-and-consume.md +++ b/.agents/skills/lumina-pin/examples/push-and-consume.md @@ -19,40 +19,60 @@ Output: 得到 Pin 的雪花 ID,状态 `pending`,落在目标项目 FIFO 队 ## 消费端(项目 B · 前端) -进入前端仓库后先看队列: +### 阶段 1:只读读取约束并评估决策(不改变 pending 状态) + +进入前端仓库后,只读预览队首待处理约束详情与正文: ```json { - "project_name": "Lumina-Frontend", - "status": "pending", - "page": 1, - "size": 10 + "project_name": "Lumina-Frontend" } ``` +(调用 `pin_peek`) -不确定内容时 `pin_peek`: +Output: 只读获得完整正文,状态仍为 `pending`: +```text +Pin 详情: -```json -{ "id": "1234567890123456789" } +ID: 1234567890123456789 +标题: WebSocket 消息体新增 trace_id +内容: +## 接口变更 +`qa_push_question` 广播体新增顶层 `trace_id`。interact 页错误上报需要带上该 ID。 +分类: api_change +状态: pending +优先级: high +... ``` -代码改完(上报带上 `trace_id`)再消费。只处理了这一条就精确消费: +或者调用 `pin_list` 查看全部待处理约束及其内容正文: ```json { "project_name": "Lumina-Frontend", - "id": "1234567890123456789" + "status": "pending", + "page": 1, + "size": 10 } ``` -按时间挨个清队列时不传 `id`: +### 阶段 2:展开本地代码适配与修复 + +根据读取到的正文要求,在前端代码中进行适配(例如为 WebSocket 错误上报带上 `trace_id`),并运行测试确认无误。 + +### 阶段 3:显式消费闭环约束 (`pin_consume`) + +代码修改完成并验证通过后,调用 `pin_consume` 显式确认闭环: ```json -{ "project_name": "Lumina-Frontend" } +{ + "project_name": "Lumina-Frontend", + "id": "1234567890123456789" +} ``` ## 反例 -- 未改代码就 FIFO 连消三条 → 约束被归档,下游会漏适配 +- 未看内容或未改代码就盲目批量调 `pin_consume` → 导致约束漏适配 - `pin_update` 把状态改成 consumed → 工具不允许,状态只能 `pin_consume` - 把「记得跑一下测试」推进 Pin → 这不是跨项目契约 diff --git a/.agents/skills/lumina-pin/references/fields.md b/.agents/skills/lumina-pin/references/fields.md index 9a37e97..e4b4ee5 100644 --- a/.agents/skills/lumina-pin/references/fields.md +++ b/.agents/skills/lumina-pin/references/fields.md @@ -21,9 +21,9 @@ ## 队列 -- `pin_list(status="pending")` 按 `createdAt` 升序,FIFO 可见。 -- 不传 `id` 的 `pin_consume` 取最旧 pending;队列空时返回「暂无待处理约束」,不是错误。 -- `pin_peek` 只读,不改状态。 +- `pin_peek` 只读查看特定或队首待处理约束详情(包含完整正文),完全不改变状态,用于消费前的决策审阅。 +- `pin_list(status="pending")` 按 `createdAt` 升序,包含正文内容,便于通览并决策。 +- `pin_consume` 在代码适配完成后调用,显式将约束状态原子更新为 `consumed`;队列空时返回「暂无待处理约束」,不是错误。 - `pin_update` 只能改 `priority` / `category`,不能把状态改成 `consumed`。 ## 不要当成 Pin 的东西 diff --git a/internal/logic/pin.go b/internal/logic/pin.go index be7d257..4667b03 100644 --- a/internal/logic/pin.go +++ b/internal/logic/pin.go @@ -249,6 +249,21 @@ func (l *PinLogic) Peek(ctx context.Context, id xSnowflake.SnowflakeID) (*apiPin return l.toResponse(pin), nil } +// PeekOldestPending 只读查看指定项目最旧的一条待处理 Pin(只读不改变状态) +// 供消费前的详情审阅与决策分析使用。 +func (l *PinLogic) PeekOldestPending(ctx context.Context, projectID xSnowflake.SnowflakeID) (*apiPin.PinResponse, *xError.Error) { + l.log.Info(ctx, fmt.Sprintf("PeekOldestPending - 只读查看队首待处理 Pin [projectID=%d]", projectID.Int64())) + + pin, xErr := l.repo.pin.GetOldestPending(ctx, projectID) + if xErr != nil { + return nil, xErr + } + if pin == nil { + return nil, xError.NewError(ctx, xError.NotFound, "暂无待处理约束", false, nil) + } + return l.toResponse(pin), nil +} + // toResponse 将 Pin 实体映射为响应 DTO // // 时间格式遵循 RFC3339(与项目模块 toResponse 保持一致)。 diff --git a/internal/logic/pin_lifecycle_test.go b/internal/logic/pin_lifecycle_test.go new file mode 100644 index 0000000..f6a07f1 --- /dev/null +++ b/internal/logic/pin_lifecycle_test.go @@ -0,0 +1,361 @@ +package logic + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + xError "github.com/bamboo-services/bamboo-base-go/common/error" + xLog "github.com/bamboo-services/bamboo-base-go/common/log" + xSnowflake "github.com/bamboo-services/bamboo-base-go/common/snowflake" + xModels "github.com/bamboo-services/bamboo-base-go/major/models" + "github.com/redis/go-redis/v9" + bConst "github.com/xiaolfeng/Lumina/internal/constant" + "github.com/xiaolfeng/Lumina/internal/entity" + "github.com/xiaolfeng/Lumina/internal/repository" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupPinTestEnv 构建基于 SQLite 与 MiniRedis 的 Pin 测试环境 +func setupPinTestEnv(t *testing.T) (*PinLogic, *gorm.DB, *entity.Project) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "pin-lifecycle.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&entity.Project{}); err != nil { + t.Fatalf("auto migrate sqlite project: %v", err) + } + // 在 SQLite 中,entity.Pin 的 ConsumedAt 标签为 type:timestamptz(用于 PostgreSQL), + // mattn/go-sqlite3 仅对 DATETIME/TIMESTAMP 识别并将其转换为 time.Time, + // 若声明为 timestamptz 则会作为 string 返回导致 Scan 错误。 + // 因此在 SQLite 测试中创建 pins 表时显式使用 DATETIME。 + if err := db.Exec(`CREATE TABLE IF NOT EXISTS pins ( + id INTEGER PRIMARY KEY, + created_at DATETIME, + updated_at DATETIME, + from_project_id INTEGER NOT NULL, + to_project_id INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(16) DEFAULT 'notice', + status VARCHAR(16) DEFAULT 'pending', + priority VARCHAR(16) DEFAULT 'medium', + consumed_at DATETIME + )`).Error; err != nil { + t.Fatalf("create pins table in sqlite: %v", err) + } + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + t.Cleanup(mr.Close) + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + projectRepo := repository.NewProjectRepo(db, rdb) + pRepo := repository.NewPinRepo(db) + + workspaceID := xSnowflake.GenerateID(bConst.GeneWorkspace) + projectID := xSnowflake.GenerateID(bConst.GeneProject) + project := &entity.Project{ + BaseEntity: xModels.BaseEntity{ID: projectID}, + WorkspaceID: workspaceID, + Name: "Pin Target Project", + AliasName: "target-proj", + } + if err := db.Create(project).Error; err != nil { + t.Fatalf("create test project: %v", err) + } + + l := &PinLogic{ + logic: logic{ + log: xLog.WithName(xLog.NamedLOGC, "PinLifecycleTest"), + }, + repo: pinRepo{ + pin: pRepo, + project: projectRepo, + }, + } + + return l, db, project +} + +// TestPinLifecycle_PeekAndConsume 严格验证 Pin 生命周期闭环: +// 1. 插入 pending 约束; +// 2. 调用 PeekOldestPending,断言返回正确标题与正文; +// 3. 关键断言:断言该约束在数据库/存储中的状态依然为 pending,consumed_at 为空; +// 4. 再次调用 Peek,断言仍然能够重复只读读取,状态未变; +// 5. 随后调用 Consume,断言状态被原子更新为 consumed,consumed_at 被正确设置; +// 6. 再次调用 PeekOldestPending,断言此时无待消费约束(NotFound)。 +func TestPinLifecycle_PeekAndConsume(t *testing.T) { + l, db, project := setupPinTestEnv(t) + ctx := context.Background() + + // Step 0: 尚未插入任何约束时,PeekOldestPending 应返回 NotFound + emptyResp, xErr := l.PeekOldestPending(ctx, project.ID) + if emptyResp != nil { + t.Fatalf("expected nil resp on empty queue, got %v", emptyResp) + } + if xErr == nil || xErr.GetErrorCode() != xError.NotFound { + t.Fatalf("expected NotFound on empty queue, got %v", xErr) + } + + // 验证 repo.GetOldestPending 在无记录时返回 nil, nil + repoEmptyPin, repoErr := l.repo.pin.GetOldestPending(ctx, project.ID) + if repoErr != nil { + t.Fatalf("repo.GetOldestPending error on empty queue: %v", repoErr) + } + if repoEmptyPin != nil { + t.Fatalf("expected nil repoEmptyPin, got %v", repoEmptyPin) + } + + // Step 1: 插入一条 pending 约束 + pinID := xSnowflake.GenerateID(bConst.GenePin) + expectedTitle := "接口 breaking change 注意事项" + expectedContent := "用户鉴权端点重构为 OAuth 2.1,需要同步移除所有旧 token 依赖。" + now := time.Now().Truncate(time.Second) + + pin := &entity.Pin{ + BaseEntity: xModels.BaseEntity{ + ID: pinID, + CreatedAt: now, + UpdatedAt: now, + }, + FromProjectID: project.ID, + ToProjectID: project.ID, + Title: expectedTitle, + Content: expectedContent, + Category: "breaking", + Status: bConst.PinStatusPending, + Priority: bConst.PinPriorityHigh, + ConsumedAt: nil, + } + if err := db.Create(pin).Error; err != nil { + t.Fatalf("create pin entity: %v", err) + } + + // Step 2: 调用 PeekOldestPending,断言返回正确的标题与正文 + peekResp, xErr := l.PeekOldestPending(ctx, project.ID) + if xErr != nil { + t.Fatalf("PeekOldestPending failed: %s", xErr.Error()) + } + if peekResp == nil { + t.Fatal("PeekOldestPending returned nil response") + } + if peekResp.ID != pinID { + t.Errorf("peekResp.ID = %s, want %s", peekResp.ID, pinID) + } + if peekResp.Title != expectedTitle { + t.Errorf("peekResp.Title = %q, want %q", peekResp.Title, expectedTitle) + } + if peekResp.Content != expectedContent { + t.Errorf("peekResp.Content = %q, want %q", peekResp.Content, expectedContent) + } + if peekResp.Status != bConst.PinStatusPending { + t.Errorf("peekResp.Status = %s, want %s", peekResp.Status, bConst.PinStatusPending) + } + if peekResp.ConsumedAt != "" { + t.Errorf("peekResp.ConsumedAt = %q, want empty string", peekResp.ConsumedAt) + } + + // 同时验证 repo.GetOldestPending 的只读返回 + repoPin, repoXErr := l.repo.pin.GetOldestPending(ctx, project.ID) + if repoXErr != nil { + t.Fatalf("repo.GetOldestPending failed: %s", repoXErr.Error()) + } + if repoPin == nil || repoPin.ID != pinID { + t.Fatalf("repo.GetOldestPending id mismatch: got %v, want %s", repoPin, pinID) + } + + // Step 3 (关键断言): 断言该约束在数据库/存储中的状态依然为 pending,consumed_at 为空 + var dbPinAfterPeek entity.Pin + if err := db.Where("id = ?", pinID).First(&dbPinAfterPeek).Error; err != nil { + t.Fatalf("failed to query pin from db: %v", err) + } + if dbPinAfterPeek.Status != bConst.PinStatusPending { + t.Fatalf("CRITICAL: DB status was modified by peek! got %s, want %s", dbPinAfterPeek.Status, bConst.PinStatusPending) + } + if dbPinAfterPeek.ConsumedAt != nil { + t.Fatalf("CRITICAL: DB consumed_at was set by peek! got %v, want nil", dbPinAfterPeek.ConsumedAt) + } + + // Step 4: 再次调用 PeekOldestPending 与 Peek(id),断言仍然能够重复只读读取,状态未变 + for i := 0; i < 3; i++ { + repeatPeekResp, repeatErr := l.PeekOldestPending(ctx, project.ID) + if repeatErr != nil { + t.Fatalf("repeat %d PeekOldestPending failed: %v", i, repeatErr) + } + if repeatPeekResp.Status != bConst.PinStatusPending || repeatPeekResp.ConsumedAt != "" { + t.Fatalf("repeat %d peek returned non-pending status: %s", i, repeatPeekResp.Status) + } + + repeatByIDResp, repeatByIDErr := l.Peek(ctx, pinID) + if repeatByIDErr != nil { + t.Fatalf("repeat %d Peek by ID failed: %v", i, repeatByIDErr) + } + if repeatByIDResp.Status != bConst.PinStatusPending || repeatByIDResp.ConsumedAt != "" { + t.Fatalf("repeat %d Peek by ID returned non-pending status: %s", i, repeatByIDResp.Status) + } + } + + // 再次检查 DB 状态确认绝对只读幂等 + if err := db.Where("id = ?", pinID).First(&dbPinAfterPeek).Error; err != nil { + t.Fatalf("query db after repeat peek: %v", err) + } + if dbPinAfterPeek.Status != bConst.PinStatusPending || dbPinAfterPeek.ConsumedAt != nil { + t.Fatalf("DB state polluted after repeated peeks: status=%s, consumed_at=%v", dbPinAfterPeek.Status, dbPinAfterPeek.ConsumedAt) + } + + // Step 5: 随后调用 Consume,断言状态被原子更新为 consumed,consumed_at 被正确设置 + consumeResp, consumeErr := l.Consume(ctx, project.ID, nil) + if consumeErr != nil { + t.Fatalf("Consume failed: %s", consumeErr.Error()) + } + if consumeResp == nil { + t.Fatal("Consume returned nil response") + } + if consumeResp.ID != pinID { + t.Errorf("consumeResp.ID = %s, want %s", consumeResp.ID, pinID) + } + if consumeResp.Status != bConst.PinStatusConsumed { + t.Errorf("consumeResp.Status = %s, want %s", consumeResp.Status, bConst.PinStatusConsumed) + } + if consumeResp.ConsumedAt == "" { + t.Error("consumeResp.ConsumedAt should not be empty") + } + + // 关键断言:直接查询数据库验证底层存储状态已被正确更新为 consumed,consumed_at 非空 + var dbPinAfterConsume entity.Pin + if err := db.Where("id = ?", pinID).First(&dbPinAfterConsume).Error; err != nil { + t.Fatalf("failed to query pin from db after consume: %v", err) + } + if dbPinAfterConsume.Status != bConst.PinStatusConsumed { + t.Fatalf("DB status not consumed: got %s", dbPinAfterConsume.Status) + } + if dbPinAfterConsume.ConsumedAt == nil { + t.Fatal("DB consumed_at is nil after consume") + } + + // Step 6: 再次调用 PeekOldestPending,断言此时无待消费约束(NotFound) + postConsumePeek, postConsumeErr := l.PeekOldestPending(ctx, project.ID) + if postConsumePeek != nil { + t.Fatalf("expected nil response after consume, got %v", postConsumePeek) + } + if postConsumeErr == nil { + t.Fatal("expected error from PeekOldestPending after all pins consumed") + } + if postConsumeErr.GetErrorCode() != xError.NotFound { + t.Fatalf("expected NotFound error code, got %v", postConsumeErr.GetErrorCode()) + } + + // 同时验证 repo.GetOldestPending 也返回 nil, nil + postConsumeRepoPin, postConsumeRepoErr := l.repo.pin.GetOldestPending(ctx, project.ID) + if postConsumeRepoErr != nil { + t.Fatalf("repo.GetOldestPending failed after consume: %v", postConsumeRepoErr) + } + if postConsumeRepoPin != nil { + t.Fatalf("repo.GetOldestPending should be nil after consume, got %v", postConsumeRepoPin) + } + + // 补充校验:通过 ID 依然可以 Peek 到已消费的 Pin 历史记录 + consumedHistory, peekHistoryErr := l.Peek(ctx, pinID) + if peekHistoryErr != nil { + t.Fatalf("Peek by ID for consumed pin failed: %v", peekHistoryErr) + } + if consumedHistory.Status != bConst.PinStatusConsumed { + t.Errorf("consumedHistory.Status = %s, want %s", consumedHistory.Status, bConst.PinStatusConsumed) + } + if consumedHistory.ConsumedAt == "" { + t.Error("consumedHistory.ConsumedAt should show timestamp") + } +} + +// TestPinLifecycle_FIFOOrdering 验证多条待处理约束时的 FIFO 队列顺序: +// 1. 插入多条 pending 约束(不同创建时间); +// 2. Peek 始终返回最早创建的约束; +// 3. 消费队首后,Peek 顺延至下一条约束; +// 4. 全部消费后,Peek 返回 NotFound。 +func TestPinLifecycle_FIFOOrdering(t *testing.T) { + l, db, project := setupPinTestEnv(t) + ctx := context.Background() + + t1 := time.Now().Add(-10 * time.Minute) + t2 := time.Now().Add(-5 * time.Minute) + + pin1ID := xSnowflake.GenerateID(bConst.GenePin) + pin1 := &entity.Pin{ + BaseEntity: xModels.BaseEntity{ID: pin1ID, CreatedAt: t1, UpdatedAt: t1}, + FromProjectID: project.ID, + ToProjectID: project.ID, + Title: "第一条约束(旧)", + Content: "这是较早创建的约束内容", + Status: bConst.PinStatusPending, + Priority: bConst.PinPriorityLow, + } + pin2ID := xSnowflake.GenerateID(bConst.GenePin) + pin2 := &entity.Pin{ + BaseEntity: xModels.BaseEntity{ID: pin2ID, CreatedAt: t2, UpdatedAt: t2}, + FromProjectID: project.ID, + ToProjectID: project.ID, + Title: "第二条约束(新)", + Content: "这是较晚创建的约束内容", + Status: bConst.PinStatusPending, + Priority: bConst.PinPriorityHigh, + } + + if err := db.Create(pin1).Error; err != nil { + t.Fatalf("create pin1: %v", err) + } + if err := db.Create(pin2).Error; err != nil { + t.Fatalf("create pin2: %v", err) + } + + // 1. Peek 最早的一条,应为 pin1 + peek1, xErr := l.PeekOldestPending(ctx, project.ID) + if xErr != nil { + t.Fatalf("peek oldest failed: %v", xErr) + } + if peek1.ID != pin1ID { + t.Fatalf("expected pin1 (%s) as oldest, got %s", pin1ID, peek1.ID) + } + + // 2. 消费队首(pin1) + c1, xErr := l.Consume(ctx, project.ID, nil) + if xErr != nil { + t.Fatalf("consume pin1 failed: %v", xErr) + } + if c1.ID != pin1ID { + t.Fatalf("consumed id = %s, want %s", c1.ID, pin1ID) + } + + // 3. 再次 Peek,应该顺延到 pin2 + peek2, xErr := l.PeekOldestPending(ctx, project.ID) + if xErr != nil { + t.Fatalf("peek next oldest failed: %v", xErr) + } + if peek2.ID != pin2ID { + t.Fatalf("expected pin2 (%s), got %s", pin2ID, peek2.ID) + } + + // 4. 精确消费 pin2 + c2, xErr := l.Consume(ctx, project.ID, &pin2ID) + if xErr != nil { + t.Fatalf("consume pin2 by id failed: %v", xErr) + } + if c2.ID != pin2ID { + t.Fatalf("consumed id = %s, want %s", c2.ID, pin2ID) + } + + // 5. 再次 Peek,队列为空 + peekEmpty, xErr := l.PeekOldestPending(ctx, project.ID) + if peekEmpty != nil || xErr == nil || xErr.GetErrorCode() != xError.NotFound { + t.Fatalf("expected NotFound on exhausted queue, got resp=%v, err=%v", peekEmpty, xErr) + } +} diff --git a/internal/mcp/pin_tools.go b/internal/mcp/pin_tools.go index 3c254d5..c797644 100644 --- a/internal/mcp/pin_tools.go +++ b/internal/mcp/pin_tools.go @@ -69,11 +69,12 @@ from_project_id 必填,表示约束来源项目(雪花 ID 或别名)。`, }, { name: "pin_consume", - description: `消费目标项目队列中的约束(FIFO 先进先出)。Agent 用于按序处理待处理约束。 + description: `消费目标项目队列中的约束,将其状态置为 consumed。 +触发场景:Agent 已经通过 pin_list 或 pin_peek 读取并审阅了约束正文,在做出决策并完成相应代码适配处理后,调用本工具显式确认消费并闭环。 支持两种消费模式: - - 不传 id 时:消费队首约束(最旧的 pending,FIFO) - - 传 id 时:精确消费指定 ID 的约束(仅当该约束归属此项目且状态为 pending 时成功) + - 传 id(推荐):精确消费已完成处理的指定 ID 约束 + - 不传 id:按 FIFO 消费队首待处理约束 project_name 只接受雪花 ID 或别名,不是 Project.Name。 @@ -105,12 +106,12 @@ project_name 只接受雪花 ID 或别名,不是 Project.Name。 }, { name: "pin_list", - description: `列出目标项目的约束列表,支持状态/分类/优先级筛选和分页。 + description: `列出目标项目的约束列表,包含标题、正文、分类与优先级(只读,不改变状态)。 project_name 只接受雪花 ID 或别名,不是 Project.Name。 默认返回 pending 状态的约束;可指定 status 查看 consumed(已消费)等历史约束。 -排序为 FIFO(创建时间升序),便于消费场景查看队列顺序。`, +排序为 FIFO(创建时间升序),便于通览待处理约束并在本地进行决策分析。`, inputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -180,19 +181,25 @@ project_name 只接受雪花 ID 或别名,不是 Project.Name。 }, { name: "pin_peek", - description: `查看指定约束的详情(只读,不改变状态)。已消费的约束也可查看。 + description: `只读查看约束详情(不改变 pending / consumed 状态)。 +触发场景:在消费前审阅约束正文内容、评估影响并做出技术决策,或在消费后回查历史约束。 -触发场景:需要在消费前预览约束完整内容、或在消费后回查历史约束时使用。 -返回完整字段:ID、标题、内容、分类、状态、优先级、来源项目、目标项目、消费时间(如已消费)、创建/更新时间。`, +支持两种查询模式: + - 传 id:精确查看指定 ID 的约束详情 + - 传 project_name(不传 id):只读预览目标项目当前队首的最旧 pending 待处理约束 +二者至少提供一个。状态始终保持原样,不会被标记为 consumed。`, inputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "id": map[string]any{ "type": "string", - "description": "要查看的 Pin ID(雪花 ID 字符串)", + "description": "要查看的 Pin ID(雪花 ID 字符串,可选)", + }, + "project_name": map[string]any{ + "type": "string", + "description": "目标项目的雪花 ID 或别名(可选,不传 id 时用于只读预览队首待处理约束)", }, }, - "required": []string{"id"}, }, }, } @@ -356,7 +363,10 @@ func handlePinList(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolRe totalPages := (resp.Total + int64(size) - 1) / int64(size) result := fmt.Sprintf("约束列表(共 %d 个,第 %d/%d 页):\n\n", resp.Total, page, totalPages) for i, p := range resp.Items { - result += fmt.Sprintf("%d. [%s] %s | 状态: %s | 优先级: %s\n", i+1, p.ID, p.Title, p.Status, p.Priority) + result += fmt.Sprintf("%d. [%s] %s | 分类: %s | 优先级: %s | 状态: %s\n", i+1, p.ID, p.Title, p.Category, p.Priority, p.Status) + if p.Content != "" { + result += fmt.Sprintf(" 内容:\n%s\n\n", p.Content) + } } if len(resp.Items) == 0 { result += "(暂无约束)\n" @@ -404,7 +414,7 @@ ID: %s resp.ID, resp.Title, resp.Priority, resp.Category)), nil } -// handlePinPeek 查看约束详情(只读) +// handlePinPeek 查看约束详情(只读,不改变状态) func handlePinPeek(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { if pinLogic == nil { return errorTextResult("PinLogic 未初始化,请联系管理员"), nil @@ -414,17 +424,33 @@ func handlePinPeek(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolRe return errorTextResult(errMsg), nil } idStr, _ := args["id"].(string) - if idStr == "" { - return errorTextResult("缺少必填参数: id"), nil + projectName, _ := args["project_name"].(string) + + if idStr == "" && projectName == "" { + return errorTextResult("缺少参数: 请提供 id 或 project_name(至少提供一个)"), nil } - parsedID, err := xSnowflake.ParseSnowflakeID(idStr) - if err != nil { - return errorTextResult(fmt.Sprintf("无效的 Pin ID: %s", idStr)), nil + + // 模式 1: 传 id 时精确查看 + if idStr != "" { + parsedID, err := xSnowflake.ParseSnowflakeID(idStr) + if err != nil { + return errorTextResult(fmt.Sprintf("无效的 Pin ID: %s", idStr)), nil + } + resp, xErr := pinLogic.Peek(context.Background(), parsedID) + if xErr != nil { + return errorTextResult(fmt.Sprintf("查看约束失败: %s", xErr.Error())), nil + } + return textResult(formatPinDetail(resp)), nil } - resp, xErr := pinLogic.Peek(context.Background(), parsedID) + // 模式 2: 不传 id 但传 project_name 时,只读预览队首待处理约束 + project, xErr := pinLogic.ResolveProject(context.Background(), projectName, 0) + if xErr != nil { + return errorTextResult(fmt.Sprintf("解析目标项目失败: %s", xErr.Error())), nil + } + resp, xErr := pinLogic.PeekOldestPending(context.Background(), project.ID) if xErr != nil { - return errorTextResult(fmt.Sprintf("查看约束失败: %s", xErr.Error())), nil + return errorTextResult(fmt.Sprintf("查看队首待处理约束失败: %s", xErr.Error())), nil } return textResult(formatPinDetail(resp)), nil } diff --git a/internal/mcp/pin_tools_test.go b/internal/mcp/pin_tools_test.go index b991747..cb965ec 100644 --- a/internal/mcp/pin_tools_test.go +++ b/internal/mcp/pin_tools_test.go @@ -1,8 +1,24 @@ package mcp import ( + "context" + "encoding/json" + "path/filepath" "strings" "testing" + "time" + + "github.com/alicebob/miniredis/v2" + xSnowflake "github.com/bamboo-services/bamboo-base-go/common/snowflake" + xCtx "github.com/bamboo-services/bamboo-base-go/defined/context" + xModels "github.com/bamboo-services/bamboo-base-go/major/models" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/redis/go-redis/v9" + bConst "github.com/xiaolfeng/Lumina/internal/constant" + "github.com/xiaolfeng/Lumina/internal/entity" + "github.com/xiaolfeng/Lumina/internal/logic" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestPinToolDescriptionsMatchResolveRules(t *testing.T) { @@ -24,4 +40,277 @@ func TestPinToolDescriptionsMatchResolveRules(t *testing.T) { if !strings.Contains(push.description, "from_project_id 必填") { t.Errorf("pin_push should say from_project_id is required") } + + // 验证 pin_peek 工具允许按 project_name 预览队首,不再强制 id 单一必填 + var peekFound bool + for _, def := range pinToolDefs { + if def.name == "pin_peek" { + peekFound = true + props, ok := def.inputSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("pin_peek properties is not map") + } + if _, hasProjectName := props["project_name"]; !hasProjectName { + t.Errorf("pin_peek should support project_name for read-only oldest pending peek") + } + if _, hasID := props["id"]; !hasID { + t.Errorf("pin_peek should support id") + } + break + } + } + if !peekFound { + t.Fatalf("pin_peek not found in pinToolDefs") + } +} + +// setupMcpPinTestEnv 初始化 MCP Pin 测试所需的环境与上下文 +func setupMcpPinTestEnv(t *testing.T) (*gorm.DB, *entity.Project) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mcp-pin.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&entity.Project{}); err != nil { + t.Fatalf("auto migrate project: %v", err) + } + + // SQLite 下兼容时间扫描,显式创建 pins 表 + if err := db.Exec(`CREATE TABLE IF NOT EXISTS pins ( + id INTEGER PRIMARY KEY, + created_at DATETIME, + updated_at DATETIME, + from_project_id INTEGER NOT NULL, + to_project_id INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(16) DEFAULT 'notice', + status VARCHAR(16) DEFAULT 'pending', + priority VARCHAR(16) DEFAULT 'medium', + consumed_at DATETIME + )`).Error; err != nil { + t.Fatalf("create pins table in sqlite: %v", err) + } + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + t.Cleanup(mr.Close) + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + ctx := context.WithValue(context.Background(), xCtx.DatabaseKey, db) + ctx = context.WithValue(ctx, xCtx.RedisClientKey, rdb) + + pLogic := logic.NewPinLogic(ctx) + SetPinLogic(pLogic) + t.Cleanup(func() { SetPinLogic(nil) }) + + workspaceID := xSnowflake.GenerateID(bConst.GeneWorkspace) + projectID := xSnowflake.GenerateID(bConst.GeneProject) + project := &entity.Project{ + BaseEntity: xModels.BaseEntity{ID: projectID}, + WorkspaceID: workspaceID, + Name: "MCP Target Project", + AliasName: "mcp-target-proj", + } + if err := db.Create(project).Error; err != nil { + t.Fatalf("create test project: %v", err) + } + + return db, project +} + +// TestPinTools_MCP_LifecyclePeekAndConsume 全链路测试 MCP 层 Pin 工具生命周期: +// 1. 模拟 pending 约束; +// 2. handlePinPeek(project_name) 只读预览队首,断言正文、标题以及未消费状态; +// 3. 关键断言:数据库底层依然为 pending,consumed_at 为空; +// 4. 重复只读调用 handlePinPeek(project_name) 验证幂等; +// 5. handlePinList(project_name) 验证返回列表包含正文内容; +// 6. handlePinConsume(project_name) 显式确认消费; +// 7. 再次 handlePinPeek(project_name) 断言暂无待处理约束; +// 8. handlePinPeek(id) 验证已消费约束的历史回查。 +func TestPinTools_MCP_LifecyclePeekAndConsume(t *testing.T) { + db, project := setupMcpPinTestEnv(t) + + // Step 0: 缺少参数调用 handlePinPeek + emptyArgs, _ := json.Marshal(map[string]any{}) + emptyRes, err := handlePinPeek(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: emptyArgs}, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !emptyRes.IsError { + t.Fatal("expected error for empty args") + } + text := emptyRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(text, "缺少参数") { + t.Fatalf("unexpected error text: %s", text) + } + + // Step 1: 插入一条 pending 约束 + pinID := xSnowflake.GenerateID(bConst.GenePin) + title := "跨服务链路 trace 追踪头透传" + content := "请在所有 HTTP 外部请求 Header 中追加 X-Trace-ID,以支持分布式全链路追踪。" + now := time.Now().Truncate(time.Second) + + pin := &entity.Pin{ + BaseEntity: xModels.BaseEntity{ID: pinID, CreatedAt: now, UpdatedAt: now}, + FromProjectID: project.ID, + ToProjectID: project.ID, + Title: title, + Content: content, + Category: "trace", + Status: bConst.PinStatusPending, + Priority: bConst.PinPriorityHigh, + } + if err := db.Create(pin).Error; err != nil { + t.Fatalf("create pin entity: %v", err) + } + + // Step 2: 调用 handlePinPeek(project_name),只读预览队首待处理约束 + peekArgs, _ := json.Marshal(map[string]any{ + "project_name": project.AliasName, + }) + peekRes, err := handlePinPeek(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: peekArgs}, + }) + if err != nil { + t.Fatalf("handlePinPeek returned go error: %v", err) + } + if peekRes.IsError { + t.Fatalf("handlePinPeek returned MCP error: %s", peekRes.Content[0].(*mcp.TextContent).Text) + } + peekText := peekRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(peekText, "标题: "+title) { + t.Errorf("peekText missing title: %s", peekText) + } + if !strings.Contains(peekText, "内容: "+content) { + t.Errorf("peekText missing content: %s", peekText) + } + if !strings.Contains(peekText, "状态: pending") { + t.Errorf("peekText status is not pending: %s", peekText) + } + if !strings.Contains(peekText, "消费时间: (未消费)") { + t.Errorf("peekText consumedAt should be unconsumed: %s", peekText) + } + + // Step 3 (关键断言): 断言该约束在数据库/存储中的状态依然为 pending,consumed_at 为空 + var dbPin entity.Pin + if err := db.Where("id = ?", pinID).First(&dbPin).Error; err != nil { + t.Fatalf("query db pin failed: %v", err) + } + if dbPin.Status != bConst.PinStatusPending { + t.Fatalf("CRITICAL: DB status was altered by handlePinPeek! got %s, want %s", dbPin.Status, bConst.PinStatusPending) + } + if dbPin.ConsumedAt != nil { + t.Fatalf("CRITICAL: DB consumed_at was altered by handlePinPeek! got %v, want nil", dbPin.ConsumedAt) + } + + // Step 4: 再次调用 handlePinPeek(project_name),断言仍然能够重复只读读取,状态未变 + for i := 0; i < 2; i++ { + repeatRes, err := handlePinPeek(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: peekArgs}, + }) + if err != nil || repeatRes.IsError { + t.Fatalf("repeat peek %d failed: err=%v, res=%v", i, err, repeatRes) + } + repeatText := repeatRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(repeatText, "状态: pending") || !strings.Contains(repeatText, "消费时间: (未消费)") { + t.Fatalf("repeat peek %d state modified: %s", i, repeatText) + } + } + + // Step 5: 调用 handlePinList(project_name),断言返回结果包含每条约束的正文内容(Content) + listArgs, _ := json.Marshal(map[string]any{ + "project_name": project.AliasName, + }) + listRes, err := handlePinList(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: listArgs}, + }) + if err != nil { + t.Fatalf("handlePinList returned go error: %v", err) + } + if listRes.IsError { + t.Fatalf("handlePinList returned MCP error: %s", listRes.Content[0].(*mcp.TextContent).Text) + } + listText := listRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(listText, title) { + t.Errorf("listText missing title: %s", listText) + } + if !strings.Contains(listText, "分类: trace | 优先级: high | 状态: pending") { + t.Errorf("listText missing meta info: %s", listText) + } + if !strings.Contains(listText, "内容:\n"+content) { + t.Errorf("listText missing pin content: %s", listText) + } + + // Step 6: 随后调用 handlePinConsume(project_name),断言状态被原子更新为 consumed,consumed_at 被正确设置 + consumeArgs, _ := json.Marshal(map[string]any{ + "project_name": project.AliasName, + }) + consumeRes, err := handlePinConsume(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: consumeArgs}, + }) + if err != nil { + t.Fatalf("handlePinConsume returned go error: %v", err) + } + if consumeRes.IsError { + t.Fatalf("handlePinConsume returned MCP error: %s", consumeRes.Content[0].(*mcp.TextContent).Text) + } + consumeText := consumeRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(consumeText, "消费成功!") { + t.Errorf("consumeText missing success message: %s", consumeText) + } + + // 关键断言:直接查询底层 DB 验证状态变更 + if err := db.Where("id = ?", pinID).First(&dbPin).Error; err != nil { + t.Fatalf("query db pin after consume failed: %v", err) + } + if dbPin.Status != bConst.PinStatusConsumed { + t.Fatalf("DB status not consumed: got %s", dbPin.Status) + } + if dbPin.ConsumedAt == nil { + t.Fatal("DB consumed_at is nil after consume") + } + + // Step 7: 再次调用 handlePinPeek(project_name),断言此时无待消费约束(NotFound) + postConsumeRes, err := handlePinPeek(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: peekArgs}, + }) + if err != nil { + t.Fatalf("handlePinPeek returned go error: %v", err) + } + if !postConsumeRes.IsError { + t.Fatal("expected error from handlePinPeek after all pins consumed") + } + postConsumeText := postConsumeRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(postConsumeText, "查看队首待处理约束失败: 暂无待处理约束") { + t.Errorf("unexpected error text: %s", postConsumeText) + } + + // Step 8: 调用 handlePinPeek(id),验证已消费的历史约束依然可以通过 ID 精确查看 + peekByIDArgs, _ := json.Marshal(map[string]any{ + "id": pinID.String(), + }) + peekByIDRes, err := handlePinPeek(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: peekByIDArgs}, + }) + if err != nil { + t.Fatalf("handlePinPeek by ID returned go error: %v", err) + } + if peekByIDRes.IsError { + t.Fatalf("handlePinPeek by ID returned MCP error: %s", peekByIDRes.Content[0].(*mcp.TextContent).Text) + } + peekByIDText := peekByIDRes.Content[0].(*mcp.TextContent).Text + if !strings.Contains(peekByIDText, "状态: consumed") { + t.Errorf("expected consumed status in history peek: %s", peekByIDText) + } + if strings.Contains(peekByIDText, "消费时间: (未消费)") { + t.Errorf("consumedAt should show actual timestamp in history peek: %s", peekByIDText) + } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index bcdc864..227e19b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -27,7 +27,7 @@ func InitMCPServer(ctx context.Context) http.Handler { 3. 需要可视化前端文件时,使用 Preview:preview_session_list/create → 逐文件 preview_file_upload → preview_file_list 最终核对。Preview 可运行原生 HTML/CSS/JavaScript,以及通过固定版本 CDN 浏览器构建或预先构建静态产物实现的 React/Vue 原型;不提供 npm 安装、打包器、SSR 或服务端执行。文件变更后的实时效果是 iframe 自动刷新,不是框架 HMR。Preview 是评审和沟通媒介,不能替代真实项目文件的实现、测试与交付。 4. Preview 核对通过后分两种交付:直接视觉评审时,优先用 MCP 客户端原生浏览器/打开链接能力访问返回的绝对 preview_url;没有该能力时向用户提供可点击 URL。作为 Q&A 补充时,调用 qa_push_supplement,content_type=preview,content 原样使用 Preview 返回的 qa_supplement 区段下的 content 标签,然后再 qa_get_answer。 5. Preview 的 hash 仅用于网页 URL;Q&A preview 引用必须使用 session_id + file_id。不得自行拼错字段,也不得给 JSON 加 Markdown 围栏。 -6. RepoWiki MCP 工具只读;Wiki 更新由 Git Webhook 驱动。Pin 仅用于明确的跨项目约束,消费前可先 pin_peek/list 核对。 +6. RepoWiki MCP 工具只读;Wiki 更新由 Git Webhook 驱动。Pin 仅用于明确的跨项目约束;进入目标项目先通过 pin_peek / pin_list 读取约束正文并进行决策评估,完成代码适配后显式调用 pin_consume 确认消费闭环。 7. Preview 维护完成后如需持久交付,用 pages_list 确认 slug,再 pages_promote。继续改已发布页面先 pages_fork。Pages 深拷贝 Preview 静态文件但不保存外部 CDN 资源,也没有实时同步;Pages 不设密码,访问策略只在控制台 /console/pages 配置。 所有工具都应按其 description 的「何时调用、不要调用、限制、返回与下一步」执行。工具返回 isError=true 时,先根据错误修正输入或停止流程,不得把失败结果当作成功。`, diff --git a/internal/repository/pin.go b/internal/repository/pin.go index eba65b6..98b4656 100644 --- a/internal/repository/pin.go +++ b/internal/repository/pin.go @@ -320,3 +320,22 @@ func (r *PinRepo) ConsumeByID(ctx context.Context, projectID, pinID xSnowflake.S r.log.Info(ctx, fmt.Sprintf("ConsumeByID - 消费成功 [pinID=%d]", pinID.Int64())) return &pin, nil } + +// GetOldestPending 只读查询指定项目的最旧待处理 Pin(按 created_at ASC 排序) +// 不加行锁,不修改任何状态,仅供消费前的详情预览与决策分析。 +func (r *PinRepo) GetOldestPending(ctx context.Context, projectID xSnowflake.SnowflakeID) (*entity.Pin, *xError.Error) { + r.log.Info(ctx, fmt.Sprintf("GetOldestPending - 只读获取最旧待处理 Pin [projectID=%d]", projectID.Int64())) + + var pin entity.Pin + if err := r.db.WithContext(ctx). + Where("to_project_id = ? AND status = ?", projectID, bConst.PinStatusPending). + Order("created_at ASC"). + First(&pin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + r.log.Warn(ctx, err.Error()) + return nil, xError.NewError(ctx, xError.DatabaseError, "查询待处理 Pin 失败", false, err) + } + return &pin, nil +} From c4aa0dfdf83c4eb65962412319be26aca013252d Mon Sep 17 00:00:00 2001 From: XiaoLFeng Date: Wed, 23 Sep 2026 01:45:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E8=BD=BD=E5=85=A5=E9=80=9F=E5=BA=A6=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BB=E5=8C=BA=E5=9F=9F=E5=8F=B3=E4=BE=A7=E6=B7=A1?= =?UTF-8?q?=E5=85=A5=E5=88=92=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧边栏:大幅缩短延迟与交错间隔,提速 76% - 顶栏:将 Header 导航栏调整为直接纯淡入,移除水平位移 - 主区域:统一 PageHeader 与主内容为从右向左淡入划入,消除方向冲突 --- components/src/motion/index.ts | 42 ++++----- web/src/components/page-header.tsx | 4 +- web/src/routes/console.tsx | 16 ++-- web/src/routes/interact/thank.tsx | 133 ++++++++++++++--------------- 4 files changed, 97 insertions(+), 98 deletions(-) diff --git a/components/src/motion/index.ts b/components/src/motion/index.ts index 1b717ec..f5145a3 100644 --- a/components/src/motion/index.ts +++ b/components/src/motion/index.ts @@ -1,7 +1,7 @@ -import type { Variants } from 'motion/react' +import type { Variants } from "motion/react"; /** 共享缓动 — 与登录页完全一致 */ -export const ease: [number, number, number, number] = [0.16, 1, 0.3, 1] +export const ease: [number, number, number, number] = [0.16, 1, 0.3, 1]; /** 交错子元素入场(供子页面自驱动使用) */ export const staggerContainer: Variants = { @@ -13,7 +13,7 @@ export const staggerContainer: Variants = { delayChildren: 0.05, }, }, -} +}; /** 交错子元素入场 — Main 区域从右向左滑入 */ export const staggerItem: Variants = { @@ -23,17 +23,17 @@ export const staggerItem: Variants = { x: 0, transition: { duration: 0.4, ease }, }, -} +}; -/** 交错子元素入场 — 从左向右滑入(仅标题行使用) */ +/** 交错子元素入场 — 统一从右向左滑入 */ export const staggerItemLeft: Variants = { - hidden: { opacity: 0, x: -12 }, + hidden: { opacity: 0, x: 20 }, visible: { opacity: 1, x: 0, transition: { duration: 0.4, ease }, }, -} +}; /** Sidebar 整体交错容器 — header → 各分组 → footer 按顺序载入 */ export const sidebarStaggerContainer: Variants = { @@ -41,21 +41,21 @@ export const sidebarStaggerContainer: Variants = { visible: { opacity: 1, transition: { - staggerChildren: 0.08, - delayChildren: 0.15, + staggerChildren: 0.025, + delayChildren: 0.03, }, }, -} +}; /** Sidebar 子项从左向右滑入 */ export const sidebarItem: Variants = { - hidden: { opacity: 0, x: -16 }, + hidden: { opacity: 0, x: -10 }, visible: { opacity: 1, x: 0, - transition: { duration: 0.35, ease }, + transition: { duration: 0.22, ease }, }, -} +}; /** * Sidebar 整块淡入 — 用于频繁重渲的侧边栏(如 Wiki Reader)。 @@ -67,7 +67,7 @@ export const sidebarBlockFade: Variants = { opacity: 1, transition: { duration: 0.25, ease }, }, -} +}; /** * Main 区域滑入 — 右侧内容切换时从右向左滑入。 @@ -85,7 +85,7 @@ export const mainSlideIn: Variants = { x: -8, transition: { duration: 0.15, ease }, }, -} +}; /* ─── Landing 页面动画变体 ─────────────────────────────── */ @@ -93,19 +93,19 @@ export const mainSlideIn: Variants = { export const fadeUp: Variants = { hidden: { opacity: 0, y: 18 }, visible: { opacity: 1, y: 0 }, -} +}; /** 纯淡入 — 装饰线等无位移元素 */ export const fadeIn: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1 }, -} +}; /** 缩放淡入 — 代码块、卡片等强调元素 */ export const scaleIn: Variants = { hidden: { opacity: 0, scale: 0.92 }, visible: { opacity: 1, scale: 1 }, -} +}; /** Hero 区域交错容器 — staggerChildren 0.12, delayChildren 0.08 */ export const heroStagger: Variants = { @@ -113,7 +113,7 @@ export const heroStagger: Variants = { visible: { transition: { staggerChildren: 0.12, delayChildren: 0.08 }, }, -} +}; /** Section 区域交错容器 — staggerChildren 0.1 */ export const sectionStagger: Variants = { @@ -121,7 +121,7 @@ export const sectionStagger: Variants = { visible: { transition: { staggerChildren: 0.1 }, }, -} +}; /** whileInView 视口配置 — 一次性触发,提前 80px 进入 */ -export const viewportOnce = { once: true, margin: '-80px' } as const +export const viewportOnce = { once: true, margin: "-80px" } as const; diff --git a/web/src/components/page-header.tsx b/web/src/components/page-header.tsx index f6e46ad..e4169b2 100644 --- a/web/src/components/page-header.tsx +++ b/web/src/components/page-header.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react' -import { staggerItemLeft } from '@lumina/components/motion' +import { staggerItem } from '@lumina/components/motion' interface PageHeaderProps { title: string @@ -11,7 +11,7 @@ export function PageHeader({ title, description, action }: PageHeaderProps) { return (

diff --git a/web/src/routes/console.tsx b/web/src/routes/console.tsx index 21c753c..b583805 100644 --- a/web/src/routes/console.tsx +++ b/web/src/routes/console.tsx @@ -1,4 +1,9 @@ -import { createFileRoute, Outlet, redirect, useLocation } from '@tanstack/react-router' +import { + createFileRoute, + Outlet, + redirect, + useLocation, +} from '@tanstack/react-router' import { motion, AnimatePresence } from 'motion/react' import { SidebarProvider, @@ -19,7 +24,9 @@ export const Route = createFileRoute('/console')({ if (!token && !refreshToken) { throw redirect({ to: '/auth/login', - search: { redirect: getSafeRedirect(location.href, '/console/dashboard') }, + search: { + redirect: getSafeRedirect(location.href, '/console/dashboard'), + }, }) } }, @@ -27,11 +34,10 @@ export const Route = createFileRoute('/console')({ }) const headerVariants = { - hidden: { opacity: 0, x: 20 }, + hidden: { opacity: 0 }, visible: { opacity: 1, - x: 0, - transition: { duration: 0.35, ease }, + transition: { duration: 0.3, ease }, }, } diff --git a/web/src/routes/interact/thank.tsx b/web/src/routes/interact/thank.tsx index 8c3ebc4..da17837 100644 --- a/web/src/routes/interact/thank.tsx +++ b/web/src/routes/interact/thank.tsx @@ -3,83 +3,76 @@ import { motion } from 'motion/react' import { ArrowRight, CheckCircle2, MessageSquare } from 'lucide-react' import { Button } from '@lumina/components/ui/button' -import { staggerContainer, staggerItem, staggerItemLeft } from '@lumina/components/motion' +import { staggerContainer, staggerItem } from '@lumina/components/motion' export const Route = createFileRoute('/interact/thank')({ - component: ThankPage, + component: ThankPage, }) function ThankPage() { - const navigate = useNavigate() + const navigate = useNavigate() - return ( -
- - -
- -
+ return ( +
+ + +
+ +
-

- 感谢您的回答 -

-

- 本次会话已归档,所有问答记录已保存。 - 请回到 AI Agent 继续后续的开发工作。 -

-
+

+ 感谢您的回答 +

+

+ 本次会话已归档,所有问答记录已保存。 请回到 AI Agent + 继续后续的开发工作。 +

+
- -
- -
-

- 会话已结束 -

-

- AI Agent 已归档此会话,后续问题将需要创建新的会话进行交互。 -

-
-
-
+ +
+ +
+

会话已结束

+

+ AI Agent 已归档此会话,后续问题将需要创建新的会话进行交互。 +

+
+
+
- - - - - -
- ) + + + + +
+
+ ) }