diff --git a/zhixing-web/package.json b/zhixing-web/package.json
index ccd81eb..60673a1 100644
--- a/zhixing-web/package.json
+++ b/zhixing-web/package.json
@@ -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": {
diff --git a/zhixing-web/pnpm-lock.yaml b/zhixing-web/pnpm-lock.yaml
index f6158cd..98c496f 100644
--- a/zhixing-web/pnpm-lock.yaml
+++ b/zhixing-web/pnpm-lock.yaml
@@ -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):
diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx
index 8ff1958..f764dd4 100644
--- a/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx
+++ b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx
@@ -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 (
+
+
![{slides[0]?.alt}]({slides[0]?.src})
+
+
+ )
+ },
+}))
+
+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()
+
+ 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()
+ fireEvent.load(screen.getByRole("img", { name: "方正科技 B1 参考案例图" }))
+ expect(screen.queryByRole("status")).not.toBeInTheDocument()
+
+ view.rerender()
+ 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()
@@ -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()
+ expect(
+ screen.getByRole("status", { name: "正在加载案例图" }),
+ ).toBeInTheDocument()
expect(
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
).toBeInTheDocument()
diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.tsx
index 1f6a158..e8ca710 100644
--- a/zhixing-web/src/features/selection/components/pattern-case-image.tsx
+++ b/zhixing-web/src/features/selection/components/pattern-case-image.tsx
@@ -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(null)
const matchedCase = score?.status === "failed" ? null : score?.case
+ const source = matchedCase
+ ? patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
+ : undefined
+ const [failedSource, setFailedSource] = useState(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 (
-
+
-
+
最接近案例:{matchedCase.name}
-
+
{formatScore(score?.value)} 分
@@ -30,13 +50,60 @@ export function PatternCaseImage({ score }: PatternCaseImageProps) {
案例图片暂不可用。
) : (
-
setFailedSource(source)}
- src={source}
- />
+ <>
+
+ setOpen(false)}
+ open={open}
+ plugins={[Zoom]}
+ render={{
+ buttonNext: () => null,
+ buttonPrev: () => null,
+ }}
+ slides={[
+ {
+ alt: `${matchedCase.name} B1 参考案例图`,
+ src: source,
+ },
+ ]}
+ />
+ >
)}
)
diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx
index 383edff..e03ebbc 100644
--- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx
+++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx
@@ -86,51 +86,52 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
暂无可用行情数据。
)}
-
-
- {stock.name}
-
-
- {stock.ts_code}
-
-
- ¥ {stock.close.toFixed(2)}
-
-
-
- {metricRows.map((row) => (
-
-
- {row.label}
- -
- {row.value}
-
-
- ))}
-
-
-
+ {stock.name}
+
+
+ {stock.ts_code}
+
+
+ ¥ {stock.close.toFixed(2)}
+
+
+
+ {metricRows.map((row) => (
+
+
- {row.label}
+ -
+ {row.value}
+
+
+ ))}
+
+
+
+
+
+
)
diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx
index 8a4e311..9070184 100644
--- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx
+++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx
@@ -683,7 +683,7 @@ describe("SelectionResultsPage", () => {
render()
- expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票")
+ expect(screen.getByText("正在加载更多股票…")).toBeInTheDocument()
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
})