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} + +
+ ) + }, +})) + +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) { 案例图片暂不可用。

) : ( - {`${matchedCase.name} 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) })