feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component #29

Merged
sakibcc merged 1 commits from develop into main 2026-09-02 18:54:47 +08:00
6 changed files with 234 additions and 61 deletions
+1
View File
@@ -29,6 +29,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.8",
"tailwind-merge": "^3.6.0",
"yet-another-react-lightbox": "^3.32.2",
"zustand": "^5.0.14"
},
"devDependencies": {
+25
View File
@@ -44,6 +44,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
yet-another-react-lightbox:
specifier: ^3.32.2
version: 3.32.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
@@ -1670,6 +1673,20 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yet-another-react-lightbox@3.32.2:
resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==}
engines: {node: '>=14'}
peerDependencies:
'@types/react': ^16 || ^17 || ^18 || ^19
'@types/react-dom': ^16 || ^17 || ^18 || ^19
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3089,6 +3106,14 @@ snapshots:
yallist@3.1.1: {}
yet-another-react-lightbox@3.32.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.4.3):
@@ -1,9 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { fireEvent, render, screen, within } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import type { SelectionPatternScore } from "../api/selection.types"
import { PatternCaseImage } from "./pattern-case-image"
vi.mock("yet-another-react-lightbox", () => ({
default: function MockLightbox({
close,
open,
slides,
}: {
close: () => void
open: boolean
slides: Array<{ alt?: string; src: string }>
}) {
if (!open) return null
return (
<div aria-label="案例图放大预览" role="dialog">
<img alt={slides[0]?.alt} src={slides[0]?.src} />
<button onClick={close} type="button">
关闭放大预览
</button>
</div>
)
},
}))
vi.mock("yet-another-react-lightbox/plugins/zoom", () => ({
default: {},
}))
vi.mock("yet-another-react-lightbox/styles.css", () => ({}))
function score(
caseId = "case_004",
caseName = "方正科技",
@@ -29,6 +57,59 @@ function score(
}
describe("PatternCaseImage", () => {
it("shows loading until the case image loads, then opens a lightbox", () => {
render(<PatternCaseImage score={score()} />)
expect(
screen.getByRole("status", { name: "正在加载案例图" }),
).toBeInTheDocument()
expect(
screen.queryByRole("dialog", { name: "案例图放大预览" }),
).not.toBeInTheDocument()
const image = screen.getByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(image).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
fireEvent.load(image)
expect(screen.queryByRole("status")).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole("button", {
name: "放大查看方正科技 B1 参考案例图",
}),
)
const dialog = screen.getByRole("dialog", { name: "案例图放大预览" })
expect(within(dialog).getByRole("img")).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
fireEvent.click(screen.getByRole("button", { name: "关闭放大预览" }))
expect(
screen.queryByRole("dialog", { name: "案例图放大预览" }),
).not.toBeInTheDocument()
})
it("resets loading when the selected case image changes", () => {
const view = render(<PatternCaseImage score={score()} />)
fireEvent.load(screen.getByRole("img", { name: "方正科技 B1 参考案例图" }))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
expect(
screen.getByRole("status", { name: "正在加载案例图" }),
).toBeInTheDocument()
const nextImage = screen.getByRole("img", {
name: "华纳药厂 B1 参考案例图",
})
fireEvent.load(nextImage)
expect(screen.queryByRole("status")).not.toBeInTheDocument()
})
it("renders only the best case image and falls back after a load error", () => {
const view = render(<PatternCaseImage score={score()} />)
@@ -36,16 +117,14 @@ describe("PatternCaseImage", () => {
name: "方正科技 B1 参考案例图",
})
expect(screen.getAllByRole("img")).toHaveLength(1)
expect(image).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
fireEvent.error(image)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
expect(
screen.getByRole("status", { name: "正在加载案例图" }),
).toBeInTheDocument()
expect(
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
).toBeInTheDocument()
@@ -1,4 +1,10 @@
import { useState } from "react"
import Lightbox from "yet-another-react-lightbox"
import Zoom from "yet-another-react-lightbox/plugins/zoom"
import "yet-another-react-lightbox/styles.css"
import { Skeleton } from "@/shared/ui/skeleton"
import { cn } from "@/shared/ui/utils"
import type { SelectionPatternScore } from "../api/selection.types"
import { patternCaseImageById } from "./pattern-case-assets"
@@ -8,20 +14,34 @@ interface PatternCaseImageProps {
}
export function PatternCaseImage({ score }: PatternCaseImageProps) {
const [failedSource, setFailedSource] = useState<string | null>(null)
const matchedCase = score?.status === "failed" ? null : score?.case
const source = matchedCase
? patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
: undefined
const [failedSource, setFailedSource] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(Boolean(source))
const [open, setOpen] = useState(false)
const [loadedSource, setLoadedSource] = useState(source)
if (source !== loadedSource) {
setLoadedSource(source)
setFailedSource(null)
setIsLoading(Boolean(source))
setOpen(false)
}
if (!matchedCase) return null
const source =
patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
return (
<section aria-labelledby="best-pattern-case" className="mt-2">
<section aria-labelledby="best-pattern-case" className="mt-3">
<div className="mb-2 flex items-baseline justify-between gap-3">
<h3 className="text-xs font-medium" id="best-pattern-case">
<h3
className="min-w-0 truncate text-xs font-medium"
id="best-pattern-case"
>
最接近案例:{matchedCase.name}
</h3>
<span className="text-xs tabular-nums text-muted-foreground">
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatScore(score?.value)} 分
</span>
</div>
@@ -30,13 +50,60 @@ export function PatternCaseImage({ score }: PatternCaseImageProps) {
案例图片暂不可用。
</p>
) : (
<img
alt={`${matchedCase.name} B1 参考案例图`}
className="h-40 w-full rounded-md border border-border/60 object-contain"
loading="lazy"
onError={() => setFailedSource(source)}
src={source}
/>
<>
<button
aria-busy={isLoading}
aria-label={
isLoading
? "正在加载案例图"
: `放大查看${matchedCase.name} B1 参考案例图`
}
className="relative block w-full overflow-hidden rounded-md border border-border/60 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-progress"
disabled={isLoading}
onClick={() => setOpen(true)}
type="button"
>
{isLoading ? (
<span
aria-label="正在加载案例图"
className="absolute inset-0 z-10"
role="status"
>
<Skeleton className="h-full w-full" />
</span>
) : null}
<img
alt={`${matchedCase.name} B1 参考案例图`}
className={cn(
"h-28 w-full object-contain",
isLoading && "invisible",
)}
key={source}
onError={() => {
setIsLoading(false)
setFailedSource(source)
}}
onLoad={() => setIsLoading(false)}
src={source}
/>
</button>
<Lightbox
carousel={{ finite: true }}
close={() => setOpen(false)}
open={open}
plugins={[Zoom]}
render={{
buttonNext: () => null,
buttonPrev: () => null,
}}
slides={[
{
alt: `${matchedCase.name} B1 参考案例图`,
src: source,
},
]}
/>
</>
)}
</section>
)
@@ -86,51 +86,52 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
)}
</div>
<div className="shrink-0">
<PatternCaseImage score={stock.score} />
</div>
</section>
<section
aria-labelledby="selected-stock-heading"
className="flex w-50 shrink-0 flex-col overflow-auto border-l border-border/60 p-3"
className="flex w-65 shrink-0 flex-col overflow-hidden border-l border-border/60 p-3"
>
<h2
className="truncate text-base font-semibold tracking-tight"
id="selected-stock-heading"
>
{stock.name}
</h2>
<p className="font-mono text-xs text-muted-foreground">
{stock.ts_code}
</p>
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight">
¥ {stock.close.toFixed(2)}
</p>
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
{metricRows.map((row) => (
<div
key={row.key}
className="flex items-center justify-between gap-2 py-2"
>
<dt className="shrink-0 text-muted-foreground">{row.label}</dt>
<dd className="min-w-0 truncate text-right font-medium tabular-nums">
{row.value}
</dd>
</div>
))}
</dl>
<section className="mt-3" aria-labelledby="pattern-score-details">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="pattern-score-details"
<div className="min-h-0 flex-1 overflow-auto">
<h2
className="truncate text-base font-semibold tracking-tight"
id="selected-stock-heading"
>
图形相似度评分
</h3>
<PatternScoreDetails score={stock.score} />
</section>
{stock.name}
</h2>
<p className="font-mono text-xs text-muted-foreground">
{stock.ts_code}
</p>
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight">
¥ {stock.close.toFixed(2)}
</p>
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
{metricRows.map((row) => (
<div
key={row.key}
className="flex items-center justify-between gap-2 py-2"
>
<dt className="shrink-0 text-muted-foreground">{row.label}</dt>
<dd className="min-w-0 truncate text-right font-medium tabular-nums">
{row.value}
</dd>
</div>
))}
</dl>
<section className="mt-3" aria-labelledby="pattern-score-details">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="pattern-score-details"
>
图形相似度评分
</h3>
<PatternScoreDetails score={stock.score} />
</section>
</div>
<PatternCaseImage key={stock.ts_code} score={stock.score} />
</section>
</aside>
)
@@ -683,7 +683,7 @@ describe("SelectionResultsPage", () => {
render(<SelectionResultsPage />)
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票")
expect(screen.getByText("正在加载更多股票…")).toBeInTheDocument()
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
})