feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"yet-another-react-lightbox": "^3.32.2",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+25
@@ -44,6 +44,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 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:
|
zustand:
|
||||||
specifier: ^5.0.14
|
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))
|
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:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
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:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3089,6 +3106,14 @@ snapshots:
|
|||||||
|
|
||||||
yallist@3.1.1: {}
|
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: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod-validation-error@4.0.2(zod@4.4.3):
|
zod-validation-error@4.0.2(zod@4.4.3):
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react"
|
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import type { SelectionPatternScore } from "../api/selection.types"
|
import type { SelectionPatternScore } from "../api/selection.types"
|
||||||
import { PatternCaseImage } from "./pattern-case-image"
|
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(
|
function score(
|
||||||
caseId = "case_004",
|
caseId = "case_004",
|
||||||
caseName = "方正科技",
|
caseName = "方正科技",
|
||||||
@@ -29,6 +57,59 @@ function score(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PatternCaseImage", () => {
|
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", () => {
|
it("renders only the best case image and falls back after a load error", () => {
|
||||||
const view = render(<PatternCaseImage score={score()} />)
|
const view = render(<PatternCaseImage score={score()} />)
|
||||||
|
|
||||||
@@ -36,16 +117,14 @@ describe("PatternCaseImage", () => {
|
|||||||
name: "方正科技 B1 参考案例图",
|
name: "方正科技 B1 参考案例图",
|
||||||
})
|
})
|
||||||
expect(screen.getAllByRole("img")).toHaveLength(1)
|
expect(screen.getAllByRole("img")).toHaveLength(1)
|
||||||
expect(image).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.error(image)
|
fireEvent.error(image)
|
||||||
expect(screen.queryByRole("img")).not.toBeInTheDocument()
|
expect(screen.queryByRole("img")).not.toBeInTheDocument()
|
||||||
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
|
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
|
||||||
|
|
||||||
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
|
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
|
||||||
|
expect(
|
||||||
|
screen.getByRole("status", { name: "正在加载案例图" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
|
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { useState } from "react"
|
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 type { SelectionPatternScore } from "../api/selection.types"
|
||||||
import { patternCaseImageById } from "./pattern-case-assets"
|
import { patternCaseImageById } from "./pattern-case-assets"
|
||||||
@@ -8,20 +14,34 @@ interface PatternCaseImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PatternCaseImage({ score }: PatternCaseImageProps) {
|
export function PatternCaseImage({ score }: PatternCaseImageProps) {
|
||||||
const [failedSource, setFailedSource] = useState<string | null>(null)
|
|
||||||
const matchedCase = score?.status === "failed" ? null : score?.case
|
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
|
if (!matchedCase) return null
|
||||||
|
|
||||||
const source =
|
|
||||||
patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
|
|
||||||
|
|
||||||
return (
|
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">
|
<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}
|
最接近案例:{matchedCase.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
{formatScore(score?.value)} 分
|
{formatScore(score?.value)} 分
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,13 +50,60 @@ export function PatternCaseImage({ score }: PatternCaseImageProps) {
|
|||||||
案例图片暂不可用。
|
案例图片暂不可用。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<>
|
||||||
alt={`${matchedCase.name} B1 参考案例图`}
|
<button
|
||||||
className="h-40 w-full rounded-md border border-border/60 object-contain"
|
aria-busy={isLoading}
|
||||||
loading="lazy"
|
aria-label={
|
||||||
onError={() => setFailedSource(source)}
|
isLoading
|
||||||
src={source}
|
? "正在加载案例图"
|
||||||
/>
|
: `放大查看${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>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,51 +86,52 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
|
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">
|
|
||||||
<PatternCaseImage score={stock.score} />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-labelledby="selected-stock-heading"
|
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
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
className="truncate text-base font-semibold tracking-tight"
|
<h2
|
||||||
id="selected-stock-heading"
|
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"
|
|
||||||
>
|
>
|
||||||
图形相似度评分
|
{stock.name}
|
||||||
</h3>
|
</h2>
|
||||||
<PatternScoreDetails score={stock.score} />
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
</section>
|
{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>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -683,7 +683,7 @@ describe("SelectionResultsPage", () => {
|
|||||||
|
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票")
|
expect(screen.getByText("正在加载更多股票…")).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
|
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user