Skip to content

Commit 23397f4

Browse files
committed
增强用户体验: 优化图片预览和解决方案提示
1 parent 8655120 commit 23397f4

File tree

6 files changed

+371
-48
lines changed

6 files changed

+371
-48
lines changed

src/components/ChallengeDetailPage/ChallengeDescription.tsx

Lines changed: 200 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Typography, Card, Empty } from 'antd';
1+
import { Typography, Card, Empty, Image } from 'antd';
22
import { Challenge } from '../../types/challenge';
33
import ReactMarkdown from 'react-markdown';
44
import rehypeRaw from 'rehype-raw';
55
import '../../styles/markdown.css';
66
import { useTranslation } from 'react-i18next';
7+
import { useState } from 'react';
78

89
const { Title } = Typography;
910

@@ -15,27 +16,43 @@ interface ChallengeDescriptionProps {
1516
const FALLBACK_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
1617

1718
// 图片组件
18-
const MarkdownImage = (props: any) => {
19+
const MarkdownImage = ({ node }: { node: any }) => {
1920
// 使用传入的src或回退到默认图片
20-
const imageSrc = props.src || FALLBACK_IMAGE;
21+
const imageSrc = node.properties?.src || FALLBACK_IMAGE;
2122

23+
// 如果是data:image类型的图片,直接使用原始src
24+
const isDataImage = typeof imageSrc === 'string' && imageSrc.startsWith('data:image');
25+
26+
// 检查图片源是否完整 (data:image格式但很短,可能被截断)
27+
const isTruncatedBase64 = isDataImage && imageSrc.length < 100;
28+
29+
// 解决方案:如果检测到是被截断的data:image,尝试从node属性中提取完整的图片数据
30+
// 这是处理React-Markdown可能截断长字符串的情况
31+
let fullImageSrc = imageSrc;
32+
if (isTruncatedBase64 && node && node.properties && node.properties.src) {
33+
fullImageSrc = node.properties.src;
34+
}
35+
36+
// 使用Ant Design的Image组件,支持点击预览
2237
return (
23-
<img
24-
{...props}
25-
src={imageSrc}
26-
alt={props.alt || '图片'}
38+
<Image
39+
src={fullImageSrc}
40+
alt={node.properties?.alt || '图片'}
2741
style={{
2842
maxWidth: '100%',
29-
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
3043
borderRadius: '4px',
3144
margin: '16px 0',
3245
display: 'block',
33-
...props.style
3446
}}
35-
onError={(e) => {
36-
const imgElement = e.currentTarget as HTMLImageElement;
37-
imgElement.onerror = null; // 防止循环错误
38-
imgElement.src = FALLBACK_IMAGE;
47+
preview={{
48+
mask: <div className="image-preview-mask">点击查看大图</div>,
49+
maskClassName: "image-preview-mask",
50+
rootClassName: "custom-image-preview",
51+
toolbarRender: () => (
52+
<div className="image-preview-tip">
53+
点击图片外区域关闭 | 滚轮缩放 | 左键拖动
54+
</div>
55+
)
3956
}}
4057
/>
4158
);
@@ -73,6 +90,176 @@ const ChallengeDescription: React.FC<ChallengeDescriptionProps> = ({ challenge }
7390
? challenge.descriptionMarkdownEN
7491
: challenge.descriptionMarkdown;
7592

93+
// 检查Markdown中是否包含base64图片
94+
const hasBase64Image = displayDescription.includes('data:image/') || displayDescription.includes('![image');
95+
96+
// 专门处理base64图片的情况
97+
if (hasBase64Image) {
98+
// 尝试提取整个Markdown中的base64图像
99+
let content = displayDescription;
100+
let htmlContent = '';
101+
let extractedImageUrl = '';
102+
103+
// 查找并处理console日志中显示的原始YAML数据
104+
// 从控制台界面看,原始的图片数据在YAML中存在,但渲染时被截断了
105+
// 让我们直接从YAML原始内容中提取
106+
if (challenge.sourceFile) {
107+
// 从挑战源文件中查找对应的描述
108+
// YAML中可能的格式: description-markdown: |
109+
// 爱给网站音频播放链接加密
110+
// ![image.png](data:image/png;base64,DATA)
111+
112+
// 直接尝试通过正则表达式提取文本中的图片
113+
try {
114+
// 检查是否包含完整的图片标记
115+
const firstTextPart = content.split('![')[0] || '';
116+
// 获取描述-markdown字段后的内容
117+
const markdownRegex = /description(-|\s)markdown:[\s]*\|([\s\S]*?)(\n\s*\w+:|$)/i;
118+
const markdownMatch = challenge.descriptionMarkdown.match(markdownRegex);
119+
120+
if (markdownMatch && markdownMatch[2]) {
121+
const fullMarkdown = markdownMatch[2].trim();
122+
123+
// 尝试匹配图片标记
124+
const imgRegex = /!\[(.+?)\]\((data:image\/[^)]+)\)/;
125+
const imgMatch = fullMarkdown.match(imgRegex);
126+
127+
if (imgMatch && imgMatch[2]) {
128+
// 提取图片URL以供Image组件使用
129+
extractedImageUrl = imgMatch[2];
130+
131+
// 构建HTML
132+
htmlContent = `
133+
<div>
134+
<p>${firstTextPart}</p>
135+
</div>
136+
`;
137+
}
138+
}
139+
} catch (error) {
140+
console.error('处理Markdown图片时出错:', error);
141+
}
142+
}
143+
144+
// 如果上面的方法没有找到图片,尝试直接解析Markdown
145+
if (!extractedImageUrl) {
146+
// 尝试从文本中提取完整的base64图片
147+
// 模式1: ![alt](data:image/png;base64,DATA)
148+
const imgMatches = content.match(/!\[(.+?)\]\((data:image\/.+?base64,)([^)]+)\)/);
149+
150+
if (imgMatches) {
151+
const [fullMatch, alt, prefix, base64Data] = imgMatches;
152+
extractedImageUrl = prefix + base64Data;
153+
154+
htmlContent = `
155+
<div>
156+
<p>${content.split(fullMatch)[0]}</p>
157+
<p>${content.split(fullMatch)[1] || ''}</p>
158+
</div>
159+
`;
160+
} else {
161+
// 模式2: 尝试直接匹配被截断的base64链接
162+
const truncatedMatch = content.match(/!\[(.+?)\]\((data:image\/[^)]*)\)/);
163+
164+
if (truncatedMatch) {
165+
const [fullMatch, alt] = truncatedMatch;
166+
167+
// 从YAML源中查找描述字段中的base64编码
168+
const rawYaml = challenge.descriptionMarkdown;
169+
const base64Match = rawYaml.match(/data:image\/[^)]+/);
170+
171+
if (base64Match) {
172+
extractedImageUrl = base64Match[0];
173+
174+
htmlContent = `
175+
<div>
176+
<p>${content.split(fullMatch)[0]}</p>
177+
<p>${content.split(fullMatch)[1] || ''}</p>
178+
</div>
179+
`;
180+
}
181+
}
182+
}
183+
}
184+
185+
// 最终解决方案:直接提取并创建图片元素
186+
if (!extractedImageUrl && challenge.descriptionMarkdown) {
187+
// 从截图看,图片的base64数据被错误地当作文本显示
188+
// 直接使用这些文本内容作为图片源
189+
const text = challenge.descriptionMarkdown;
190+
const textParts = displayDescription.split('\n');
191+
192+
// 找出包含爱给网站音频播放链接加密的那一行,作为第一部分
193+
const firstPart = textParts[0] || '';
194+
195+
// 检查原始文本是否包含data:image部分
196+
if (text.includes('data:image/png;base64,')) {
197+
// 截取data:image开始的部分直到结束
198+
const dataImageIndex = text.indexOf('data:image/png;base64,');
199+
if (dataImageIndex !== -1) {
200+
let endIndex = text.indexOf('"', dataImageIndex);
201+
if (endIndex === -1) endIndex = text.indexOf("'", dataImageIndex);
202+
if (endIndex === -1) endIndex = text.indexOf(')', dataImageIndex);
203+
if (endIndex === -1) endIndex = text.length;
204+
205+
extractedImageUrl = text.substring(dataImageIndex, endIndex);
206+
207+
htmlContent = `
208+
<div>
209+
<p>${firstPart}</p>
210+
</div>
211+
`;
212+
}
213+
}
214+
}
215+
216+
// 如果成功提取了图片URL,使用Ant Design的Image组件显示
217+
if (extractedImageUrl) {
218+
return (
219+
<div>
220+
<Title level={3}>{t('challenge.detail.description')}</Title>
221+
222+
<Card
223+
bordered={false}
224+
style={{
225+
marginBottom: 24,
226+
wordWrap: 'break-word',
227+
overflowWrap: 'break-word'
228+
}}
229+
>
230+
<div className="markdown-content">
231+
{htmlContent && (
232+
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
233+
)}
234+
<Image
235+
src={extractedImageUrl}
236+
alt="挑战图片"
237+
style={{
238+
maxWidth: '100%',
239+
borderRadius: '4px',
240+
margin: '16px 0',
241+
display: 'block'
242+
}}
243+
preview={{
244+
mask: '点击查看大图',
245+
maskClassName: 'image-preview-mask',
246+
toolbarRender: () => (
247+
<div className="image-preview-tip">
248+
点击图片外区域关闭 | 滚轮缩放 | 左键拖动
249+
</div>
250+
),
251+
rootClassName: 'custom-image-preview'
252+
}}
253+
fallback={FALLBACK_IMAGE}
254+
/>
255+
</div>
256+
</Card>
257+
</div>
258+
);
259+
}
260+
}
261+
262+
// 默认渲染方式 - 使用ReactMarkdown
76263
return (
77264
<div>
78265
<Title level={3}>{t('challenge.detail.description')}</Title>

src/components/ChallengeDetailPage/ChallengeSolutions.tsx

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Typography, Card } from 'antd';
1+
import { Typography, Card, Empty, Button } from 'antd';
22
import { Challenge } from '../../types/challenge';
33
import { useTranslation } from 'react-i18next';
4+
import { GithubOutlined } from '@ant-design/icons';
45

56
const { Title, Text } = Typography;
67

@@ -14,39 +15,60 @@ interface ChallengeSolutionsProps {
1415
const ChallengeSolutions: React.FC<ChallengeSolutionsProps> = ({ challenge }) => {
1516
const { t } = useTranslation();
1617

17-
if (!challenge.solutions || challenge.solutions.length === 0) {
18-
return null;
19-
}
20-
2118
return (
2219
<div>
2320
<Title level={3}>{t('challenge.detail.solutions')}</Title>
24-
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
25-
{challenge.solutions.map((solution, index) => (
26-
<Card key={index} size="small" hoverable>
27-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
28-
<div>
29-
<Text strong>{solution.title}</Text>
30-
<div style={{ marginTop: '4px' }}>
31-
<Text type="secondary">{t('challenge.detail.source')}: {solution.source}</Text>
32-
{solution.author && (
33-
<Text type="secondary" style={{ marginLeft: '12px' }}>
34-
{t('challenge.detail.author')}: {solution.author}
35-
</Text>
36-
)}
21+
22+
{(!challenge.solutions || challenge.solutions.length === 0) ? (
23+
<Card>
24+
<Empty
25+
image={Empty.PRESENTED_IMAGE_SIMPLE}
26+
description={
27+
<span>
28+
{t('challenge.detail.noSolutions', '暂无解决方案')}
29+
</span>
30+
}
31+
>
32+
<Button
33+
type="primary"
34+
icon={<GithubOutlined />}
35+
onClick={() => window.open('https://github.com/JSREP/crawler-leetcode/issues/new?template=solution.md&title=解决方案:' + challenge.name, '_blank')}
36+
>
37+
{t('challenge.detail.contributeSolution', '贡献你的解决方案')}
38+
</Button>
39+
<div style={{ marginTop: '12px', fontSize: '14px', color: 'rgba(0, 0, 0, 0.45)' }}>
40+
{t('challenge.detail.contributeTip', '欢迎分享你的解决方案,帮助更多的人!')}
41+
</div>
42+
</Empty>
43+
</Card>
44+
) : (
45+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
46+
{challenge.solutions.map((solution, index) => (
47+
<Card key={index} size="small" hoverable>
48+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
49+
<div>
50+
<Text strong>{solution.title}</Text>
51+
<div style={{ marginTop: '4px' }}>
52+
<Text type="secondary">{t('challenge.detail.source')}: {solution.source}</Text>
53+
{solution.author && (
54+
<Text type="secondary" style={{ marginLeft: '12px' }}>
55+
{t('challenge.detail.author')}: {solution.author}
56+
</Text>
57+
)}
58+
</div>
3759
</div>
60+
<a
61+
href={solution.url}
62+
target="_blank"
63+
rel="noopener noreferrer"
64+
>
65+
{t('challenge.detail.viewSolutionLink')}
66+
</a>
3867
</div>
39-
<a
40-
href={solution.url}
41-
target="_blank"
42-
rel="noopener noreferrer"
43-
>
44-
{t('challenge.detail.viewSolutionLink')}
45-
</a>
46-
</div>
47-
</Card>
48-
))}
49-
</div>
68+
</Card>
69+
))}
70+
</div>
71+
)}
5072
</div>
5173
);
5274
};

src/components/ChallengeDetailPage/index.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,8 @@ const ChallengeDetailPage = () => {
155155
<Divider />
156156

157157
{/* 解决方案 */}
158-
{challenge.solutions && challenge.solutions.length > 0 && (
159-
<>
160-
<ChallengeSolutions challenge={challenge} />
161-
<Divider />
162-
</>
163-
)}
158+
<ChallengeSolutions challenge={challenge} />
159+
<Divider />
164160

165161
{/* 外部链接和返回 */}
166162
<ChallengeActions challenge={challenge} />

src/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ export default {
150150
description: 'Problem Description',
151151
solutions: 'Reference Solutions',
152152
viewSolutionLink: 'View Solution',
153+
noSolutions: 'No solution available',
154+
contributeSolution: 'Contribute Your Solution',
155+
contributeTip: 'Share your solution and help others!',
153156
source: 'Source',
154157
author: 'Author',
155158
correction: 'Correction',

src/locales/zh.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ const zhTranslations = {
150150
description: '问题描述',
151151
solutions: '参考解答',
152152
viewSolutionLink: '查看解答',
153+
noSolutions: '暂无解决方案',
154+
contributeSolution: '贡献你的解决方案',
155+
contributeTip: '欢迎分享你的解决方案,帮助更多的人!',
153156
source: '来源',
154157
author: '作者',
155158
correction: '纠错',

0 commit comments

Comments
 (0)