diff --git a/api/pages/list.go b/api/pages/list.go index 9fdcd5a..4656bcf 100644 --- a/api/pages/list.go +++ b/api/pages/list.go @@ -6,6 +6,7 @@ import xSnowflake "github.com/bamboo-services/bamboo-base-go/common/snowflake" type PageListRequest struct { ProjectID xSnowflake.SnowflakeID `form:"project_id"` // 项目ID筛选 WorkspaceID xSnowflake.SnowflakeID `form:"workspace_id"` // 所属空间ID筛选,零值不过滤 + Status string `form:"status"` // 状态筛选,空值不过滤 Page int `form:"page"` // 页码 Size int `form:"size"` // 每页数量 } diff --git a/api/project/list.go b/api/project/list.go index 513447c..746ff6c 100644 --- a/api/project/list.go +++ b/api/project/list.go @@ -7,4 +7,5 @@ type ProjectListRequest struct { Page int `form:"page"` // 页码 Size int `form:"size"` // 每页数量 WorkspaceID xSnowflake.SnowflakeID `form:"workspace_id"` // 所属空间ID筛选,零值不过滤 + Search string `form:"search"` // 项目名称/别名模糊搜索,空值不过滤 } diff --git a/components/src/lpw/annotation.test.tsx b/components/src/lpw/annotation.test.tsx index 0317b46..c277122 100644 --- a/components/src/lpw/annotation.test.tsx +++ b/components/src/lpw/annotation.test.tsx @@ -197,7 +197,11 @@ describe('annotation', () => { expect(container.querySelector('#frame-md-annotated')).toBeTruthy() }) - it('Q-04 重叠批注压缩,点击后展开', async () => { + it('Q-04 MD 尺寸下重叠批注压缩,点击后展开;LG 尺寸下默认展开', async () => { + // 模拟 MD 大小 (例如 800px) + window.innerWidth = 800 + window.dispatchEvent(new Event('resize')) + const ann: LpwAnnotation = { kind: 'note', message: '第一条' } const ann2: LpwAnnotation = { kind: 'issue', message: '第二条' } render( @@ -220,6 +224,38 @@ describe('annotation', () => { expect(screen.getAllByTestId('annotation-bubble').length).toBeGreaterThanOrEqual( 2, ) + + // 切回 LG 尺寸 (1200px) + window.innerWidth = 1200 + window.dispatchEvent(new Event('resize')) + }) + + it('移动端/MD 浮动批注按钮与精致侧边抽屉设计', async () => { + window.innerWidth = 640 + window.dispatchEvent(new Event('resize')) + + const ann: LpwAnnotation = { kind: 'note', message: '移动端批注' } + render( +
+ + +
移动正文
+
+ +
+
, + ) + + const triggerBtn = screen.getByRole('button', { name: /查看 1 条批注/ }) + expect(triggerBtn).toBeTruthy() + fireEvent.click(triggerBtn) + + // 打开抽屉 + expect(await screen.findByText('文档批注与审查建议')).toBeTruthy() + expect(screen.getAllByText('移动端批注').length).toBeGreaterThanOrEqual(1) + + window.innerWidth = 1200 + window.dispatchEvent(new Event('resize')) }) it('supports unicode and case-insensitive flags combined (iu)', () => { diff --git a/components/src/lpw/annotation.tsx b/components/src/lpw/annotation.tsx index f9762bb..648cbfe 100644 --- a/components/src/lpw/annotation.tsx +++ b/components/src/lpw/annotation.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import type React from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "../ui/sheet"; import { useAnnotationContext } from "./annotation-context"; import type { RegisteredAnnotation } from "./annotation-context"; import { isSafeAnnotationPattern } from "./safe-pattern"; @@ -314,8 +315,20 @@ export const AnnotationGutter: React.FC = () => { const annotations = ctx?.annotations ?? []; const asideRef = useRef(null); const [tops, setTops] = useState>({}); - const [expandedKey, setExpandedKey] = useState(null); - const [mobileOpen, setMobileOpen] = useState(false); + const [isLg, setIsLg] = useState(false); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + const [collapsedKeys, setCollapsedKeys] = useState>(new Set()); + const [sheetOpen, setSheetOpen] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + const checkLg = () => { + setIsLg(window.innerWidth >= 1024); + }; + checkLg(); + window.addEventListener("resize", checkLg); + return () => window.removeEventListener("resize", checkLg); + }, []); useLayoutEffect(() => { if (annotations.length === 0) return; @@ -362,6 +375,38 @@ export const AnnotationGutter: React.FC = () => { [annotations, tops], ); + const isGroupExpanded = (key: string, groupLen: number) => { + if (groupLen <= 1) return true; + if (isLg) { + return !collapsedKeys.has(key); + } + return expandedKeys.has(key); + }; + + const handleToggleGroup = (key: string, currentlyExpanded: boolean) => { + if (currentlyExpanded) { + if (isLg) { + setCollapsedKeys((prev) => new Set([...prev, key])); + } else { + setExpandedKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + } else { + if (isLg) { + setCollapsedKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } else { + setExpandedKeys((prev) => new Set([...prev, key])); + } + } + }; + const clusterTops = useMemo(() => { const result: Record = {}; let minTop = 0; @@ -386,12 +431,12 @@ export const AnnotationGutter: React.FC = () => { ref={asideRef} data-testid="annotation-gutter" aria-label="文档批注" - className="relative hidden w-64 shrink-0 self-stretch py-2 pr-2 md:block" + className="relative hidden w-60 lg:w-64 shrink-0 self-stretch py-2 pr-2 md:block" > {clusters.map((group) => { const key = group.map((g) => g.nodeId).join("-"); const top = clusterTops[key] ?? (tops[group[0].nodeId] ?? 0); - const expanded = expandedKey === key || group.length === 1; + const expanded = isGroupExpanded(key, group.length); return (
{ {group.length > 1 ? ( - {mobileOpen ? ( -
+
+ + + + +
+
+
+ +
+
+ + 文档批注与审查建议 + + + 共 {annotations.length} 条批注 · 点击条目直达正文 + +
+
+
+
+ +
{annotations.map((item) => ( - + onClick={() => { + setSheetOpen(false); + ctx?.scrollToNode(item.nodeId); + }} + > + +
))}
- ) : null} - + + ); }; diff --git a/components/src/lpw/block/image.test.tsx b/components/src/lpw/block/image.test.tsx index 5958cd3..2ca0aaf 100644 --- a/components/src/lpw/block/image.test.tsx +++ b/components/src/lpw/block/image.test.tsx @@ -28,6 +28,33 @@ describe('ImageBlock', () => { expect(screen.getByText('图 1. 架构流向')).toBeTruthy() }) + it('默认页面级宽度应为 80%,嵌套在 layout/container 内时应为 100%', () => { + const { rerender } = render( + , + ) + const figTop = screen.getByTestId('image-block') + expect(figTop.style.width).toBe('80%') + + rerender( + , + ) + expect(figTop.style.width).toBe('100%') + }) + it('图片加载失败触发 onError 时应切换为占位卡', () => { render( > = ({ props, + location, }) => { const [hasError, setHasError] = useState(false) const runtime = useLpwRuntime() @@ -14,6 +15,9 @@ export const ImageBlock: React.FC> = ({ setHasError(false) }, [props.src]) + const isNested = Boolean(location?.idPath && location.idPath.length > 0) + const targetWidth = props.width ? props.width : isNested ? '100%' : '80%' + if (hasError) { return (
> = ({ } return ( -
-
+
+
{props.alt} setHasError(true)} - className="max-w-full object-contain bg-surface-muted/20" + className="w-full max-w-full object-contain bg-surface-muted/20" />
{props.caption && ( diff --git a/components/src/lpw/block/mermaid.test.tsx b/components/src/lpw/block/mermaid.test.tsx index a7c2c97..cf93038 100644 --- a/components/src/lpw/block/mermaid.test.tsx +++ b/components/src/lpw/block/mermaid.test.tsx @@ -23,4 +23,41 @@ describe('MermaidBlock', () => { expect(screen.getByTestId('mermaid-block')).toBeTruthy() expect(screen.getByText('图 2. 流程拓扑')).toBeTruthy() }) + + it('默认页面级宽度应为 80%,嵌套在 layout/container 内时应为 100%,且支持自定义 width', () => { + const { rerender } = render( + B', + }} + location={{ jsonPath: '/content/0', idPath: [] }} + />, + ) + const fig = screen.getByTestId('mermaid-block') + expect(fig.style.width).toBe('80%') + + rerender( + B', + }} + location={{ jsonPath: '/content/0/children/0', idPath: ['container-1'] }} + />, + ) + expect(fig.style.width).toBe('100%') + + rerender( + B', + width: '60%', + }} + location={{ jsonPath: '/content/0', idPath: [] }} + />, + ) + expect(fig.style.width).toBe('60%') + }) }) diff --git a/components/src/lpw/block/mermaid.tsx b/components/src/lpw/block/mermaid.tsx index 123d2f0..32e4e46 100644 --- a/components/src/lpw/block/mermaid.tsx +++ b/components/src/lpw/block/mermaid.tsx @@ -5,11 +5,18 @@ import { MermaidViewport } from './mermaid-viewport' export const MermaidBlock: React.FC> = ({ props, + location, }) => { const fenced = '```mermaid\n' + props.content.trim() + '\n```' + const isNested = Boolean(location?.idPath && location.idPath.length > 0) + const targetWidth = props.width ? props.width : isNested ? '100%' : '80%' return ( -
+
{fenced} diff --git a/components/src/lpw/document-viewer.test.tsx b/components/src/lpw/document-viewer.test.tsx index 714af8d..6fd97b5 100644 --- a/components/src/lpw/document-viewer.test.tsx +++ b/components/src/lpw/document-viewer.test.tsx @@ -183,7 +183,7 @@ describe('LpwDocumentViewer', () => { expect(screen.queryByText(/IMPRIMATUR/)).toBeNull() }) - it('Q-04 批注栏在纸张外部,没有视口 fixed FAB 和右上角 label', async () => { + it('Q-04 批注栏在纸张外部,没有右上角 label', async () => { const source = JSON.stringify({ version: '1.1', meta: { title: '批注纸外' }, @@ -208,12 +208,11 @@ describe('LpwDocumentViewer', () => { }, ], }) - const { container } = render() + render() const gutter = await screen.findByTestId('annotation-gutter') expect(gutter).toBeTruthy() const paper = await screen.findByTestId('lpw-paper') expect(paper.querySelector('[data-testid="annotation-gutter"]')).toBeNull() - expect(container.querySelector('.fixed')).toBeNull() expect(screen.queryByRole('button', { name: /批注:/ })).toBeNull() expect(document.getElementById('frame-md-1')).toBeTruthy() }) diff --git a/components/src/lpw/document-viewer.tsx b/components/src/lpw/document-viewer.tsx index 9d6f3be..a7865c0 100644 --- a/components/src/lpw/document-viewer.tsx +++ b/components/src/lpw/document-viewer.tsx @@ -50,10 +50,10 @@ export const LpwDocumentViewer: React.FC = ({ return ( -
+