Skip to content

Commit 9e82662

Browse files
committed
feat: 优化宝箱分段逻辑、修复间距计算、添加截图到README
1 parent ad5f6f9 commit 9e82662

9 files changed

Lines changed: 1038 additions & 43 deletions

File tree

package-lock.json

Lines changed: 852 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@types/jest": "^30.0.0",
6060
"@types/react-router-dom": "^5.3.3",
6161
"fast-check": "^4.5.3",
62-
"gh-pages": "^6.1.1"
62+
"gh-pages": "^6.1.1",
63+
"puppeteer": "^24.34.0"
6364
}
6465
}

public/screenshot-home.png

455 KB
Loading

public/screenshot-path.png

220 KB
Loading

scripts/screenshot.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const puppeteer = require('puppeteer');
2+
const path = require('path');
3+
4+
async function takeScreenshot() {
5+
const browser = await puppeteer.launch({
6+
headless: 'new',
7+
args: ['--no-sandbox', '--disable-setuid-sandbox']
8+
});
9+
10+
const page = await browser.newPage();
11+
12+
// 设置视口大小
13+
await page.setViewport({ width: 1280, height: 800 });
14+
15+
// 访问首页
16+
await page.goto('http://localhost:40140/leetcode-hot-100', {
17+
waitUntil: 'networkidle0',
18+
timeout: 30000
19+
});
20+
21+
// 等待页面加载完成
22+
await page.waitForSelector('.path-overview-container', { timeout: 15000 });
23+
24+
// 等待动画完成
25+
await new Promise(resolve => setTimeout(resolve, 3000));
26+
27+
// 截取整个页面
28+
await page.screenshot({
29+
path: path.join(__dirname, '../public/screenshot-home.png'),
30+
fullPage: true
31+
});
32+
33+
console.log('✅ 首页截图已保存到 public/screenshot-home.png');
34+
35+
// 截取路径详情页
36+
await page.goto('http://localhost:40140/leetcode-hot-100#/path/two-pointers', {
37+
waitUntil: 'networkidle0',
38+
timeout: 30000
39+
});
40+
41+
await page.waitForSelector('.duolingo-path-container', { timeout: 15000 });
42+
await new Promise(resolve => setTimeout(resolve, 3000));
43+
44+
await page.screenshot({
45+
path: path.join(__dirname, '../public/screenshot-path.png'),
46+
fullPage: true
47+
});
48+
49+
console.log('✅ 路径详情截图已保存到 public/screenshot-path.png');
50+
51+
await browser.close();
52+
console.log('🎉 截图完成!');
53+
}
54+
55+
takeScreenshot().catch(err => {
56+
console.error('截图失败:', err);
57+
process.exit(1);
58+
});

src/components/ExperienceBar/ExperienceBar.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,11 @@
261261

262262
.problem-progress-fill {
263263
height: 100%;
264-
background: linear-gradient(90deg, #22c55e 0%, #4ade80 50%, #86efac 100%);
264+
background: linear-gradient(90deg, #b45309 0%, #d97706 30%, #f59e0b 60%, #fbbf24 100%);
265265
border-radius: 4px;
266266
transition: width 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
267267
box-shadow:
268-
0 0 10px rgba(34, 197, 94, 0.4),
268+
0 0 10px rgba(245, 158, 11, 0.4),
269269
inset 0 1px 2px rgba(255, 255, 255, 0.4);
270270
position: relative;
271271
}

src/components/ExperienceBar/ExperienceBar.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,16 @@ const getChineseNumber = (num: number): string => {
4343
// - 总计约3070 EXP,对应约31级
4444
// - 境界系统设计为每个境界3层,共10个境界,确保用户能达到最终境界
4545
const REALMS: RealmInfo[] = [
46-
{ name: '练气期', nameEn: 'Qi Refining', minLevel: 1, maxLevel: 3, color: '#4ade80', icon: '🌱', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
47-
{ name: '筑基期', nameEn: 'Foundation', minLevel: 4, maxLevel: 6, color: '#22c55e', icon: '🌿', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
48-
{ name: '金丹期', nameEn: 'Golden Core', minLevel: 7, maxLevel: 9, color: '#4ade80', icon: '💫', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
49-
{ name: '元婴期', nameEn: 'Nascent Soul', minLevel: 10, maxLevel: 12, color: '#22c55e', icon: '🔥', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
50-
{ name: '化神期', nameEn: 'Spirit Severing', minLevel: 13, maxLevel: 15, color: '#4ade80', icon: '⚡', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
51-
{ name: '炼虚期', nameEn: 'Void Refining', minLevel: 16, maxLevel: 18, color: '#22c55e', icon: '🌀', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
52-
{ name: '合体期', nameEn: 'Body Integration', minLevel: 19, maxLevel: 21, color: '#4ade80', icon: '💎', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
53-
{ name: '大乘期', nameEn: 'Mahayana', minLevel: 22, maxLevel: 24, color: '#22c55e', icon: '🌸', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
54-
{ name: '渡劫期', nameEn: 'Tribulation', minLevel: 25, maxLevel: 27, color: '#4ade80', icon: '⛈️', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
55-
{ name: '大罗金仙', nameEn: 'Golden Immortal', minLevel: 28, maxLevel: 999, color: '#fbbf24', icon: '👑', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
46+
{ name: '练气期', nameEn: 'Qi Refining', minLevel: 1, maxLevel: 3, color: '#92400e', icon: '🌱', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
47+
{ name: '筑基期', nameEn: 'Foundation', minLevel: 4, maxLevel: 6, color: '#a16207', icon: '🌿', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
48+
{ name: '金丹期', nameEn: 'Golden Core', minLevel: 7, maxLevel: 9, color: '#b45309', icon: '💫', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
49+
{ name: '元婴期', nameEn: 'Nascent Soul', minLevel: 10, maxLevel: 12, color: '#ca8a04', icon: '🔥', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
50+
{ name: '化神期', nameEn: 'Spirit Severing', minLevel: 13, maxLevel: 15, color: '#d97706', icon: '⚡', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
51+
{ name: '炼虚期', nameEn: 'Void Refining', minLevel: 16, maxLevel: 18, color: '#eab308', icon: '🌀', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
52+
{ name: '合体期', nameEn: 'Body Integration', minLevel: 19, maxLevel: 21, color: '#f59e0b', icon: '💎', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
53+
{ name: '大乘期', nameEn: 'Mahayana', minLevel: 22, maxLevel: 24, color: '#fbbf24', icon: '🌸', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
54+
{ name: '渡劫期', nameEn: 'Tribulation', minLevel: 25, maxLevel: 27, color: '#fcd34d', icon: '⛈️', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
55+
{ name: '大罗金仙', nameEn: 'Golden Immortal', minLevel: 28, maxLevel: 999, color: '#fde68a', icon: '👑', bgGradient: 'linear-gradient(135deg, #0f1419 0%, #1a2332 50%, #0d1117 100%)' },
5656
];
5757

5858
// 根据等级获取境界信息

src/components/ProblemList/PathView/DuolingoPath.tsx

Lines changed: 113 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,42 @@ const SEGMENT_GAP = 220;
3232
// 节点之间的基础间距
3333
const NODE_SPACING = 180;
3434

35+
// 智能计算分段,避免最后一段太短
36+
const calculateSegments = (totalProblems: number): number[] => {
37+
// 5题及以下:不分段,只有终点宝箱
38+
if (totalProblems <= 5) {
39+
return [totalProblems];
40+
}
41+
42+
// 6-7题:分成两段(3+3 或 3+4)
43+
if (totalProblems <= 7) {
44+
const firstHalf = Math.floor(totalProblems / 2);
45+
return [firstHalf, totalProblems - firstHalf];
46+
}
47+
48+
// 8题及以上:每5题一段,但确保最后一段至少3题
49+
const segments: number[] = [];
50+
let remaining = totalProblems;
51+
52+
while (remaining > 0) {
53+
if (remaining <= 7) {
54+
// 剩余7题及以下,平均分成最后两段
55+
if (remaining <= 5) {
56+
segments.push(remaining);
57+
} else {
58+
const firstHalf = Math.floor(remaining / 2);
59+
segments.push(firstHalf);
60+
segments.push(remaining - firstHalf);
61+
}
62+
break;
63+
}
64+
segments.push(SEGMENT_SIZE);
65+
remaining -= SEGMENT_SIZE;
66+
}
67+
68+
return segments;
69+
};
70+
3571
const DuolingoPath: React.FC<DuolingoPathProps> = ({
3672
problems,
3773
allProblems,
@@ -84,17 +120,20 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
84120
// 计算分段信息和宝箱位置
85121
const segmentInfo = useMemo(() => {
86122
const totalProblems = problems.length;
87-
const segmentCount = Math.ceil(totalProblems / SEGMENT_SIZE);
123+
const segmentSizes = calculateSegments(totalProblems);
124+
const segmentCount = segmentSizes.length;
88125
const segments: {
89126
startIndex: number;
90127
endIndex: number;
91128
completedCount: number;
92129
isComplete: boolean;
93130
}[] = [];
94131

132+
let currentIndex = 0;
95133
for (let i = 0; i < segmentCount; i++) {
96-
const startIndex = i * SEGMENT_SIZE;
97-
const endIndex = Math.min(startIndex + SEGMENT_SIZE - 1, totalProblems - 1);
134+
const segmentSize = segmentSizes[i];
135+
const startIndex = currentIndex;
136+
const endIndex = currentIndex + segmentSize - 1;
98137

99138
// 计算该分段的完成数量
100139
let completedCount = 0;
@@ -104,33 +143,37 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
104143
}
105144
}
106145

107-
const segmentSize = endIndex - startIndex + 1;
108146
segments.push({
109147
startIndex,
110148
endIndex,
111149
completedCount,
112150
isComplete: completedCount === segmentSize
113151
});
152+
153+
currentIndex = endIndex + 1;
114154
}
115155

116-
return { segmentCount, segments };
156+
return { segmentCount, segments, segmentSizes };
117157
}, [problems, isCompleted]);
118158

119159
// 基于原始完整题目列表计算宝箱解锁状态(防止筛选后作弊)
120160
const originalSegmentInfo = useMemo(() => {
121161
const originalProblems = allProblems || problems;
122162
const totalProblems = originalProblems.length;
123-
const segmentCount = Math.ceil(totalProblems / SEGMENT_SIZE);
163+
const segmentSizes = calculateSegments(totalProblems);
164+
const segmentCount = segmentSizes.length;
124165
const segments: {
125166
startIndex: number;
126167
endIndex: number;
127168
completedCount: number;
128169
isComplete: boolean;
129170
}[] = [];
130171

172+
let currentIndex = 0;
131173
for (let i = 0; i < segmentCount; i++) {
132-
const startIndex = i * SEGMENT_SIZE;
133-
const endIndex = Math.min(startIndex + SEGMENT_SIZE - 1, totalProblems - 1);
174+
const segmentSize = segmentSizes[i];
175+
const startIndex = currentIndex;
176+
const endIndex = currentIndex + segmentSize - 1;
134177

135178
// 计算该分段的完成数量
136179
let completedCount = 0;
@@ -140,16 +183,17 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
140183
}
141184
}
142185

143-
const segmentSize = endIndex - startIndex + 1;
144186
segments.push({
145187
startIndex,
146188
endIndex,
147189
completedCount,
148190
isComplete: completedCount === segmentSize
149191
});
192+
193+
currentIndex = endIndex + 1;
150194
}
151195

152-
return { segmentCount, segments };
196+
return { segmentCount, segments, segmentSizes };
153197
}, [allProblems, problems, isCompleted]);
154198

155199
// 计算是否所有题目都已完成(基于原始题目列表)
@@ -160,13 +204,25 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
160204

161205
// 判断某个索引是否是分段的最后一个节点(不包括整个路径的最后一个)
162206
const isSegmentEnd = useCallback((index: number) => {
163-
return (index + 1) % SEGMENT_SIZE === 0 && index < problems.length - 1;
164-
}, [problems.length]);
207+
// 检查是否是某个分段的最后一个节点
208+
for (const segment of segmentInfo.segments) {
209+
if (index === segment.endIndex && index < problems.length - 1) {
210+
return true;
211+
}
212+
}
213+
return false;
214+
}, [segmentInfo.segments, problems.length]);
165215

166216
// 获取某个索引所在的分段编号
167217
const getSegmentIndex = useCallback((index: number) => {
168-
return Math.floor(index / SEGMENT_SIZE);
169-
}, []);
218+
for (let i = 0; i < segmentInfo.segments.length; i++) {
219+
const segment = segmentInfo.segments[i];
220+
if (index >= segment.startIndex && index <= segment.endIndex) {
221+
return i;
222+
}
223+
}
224+
return 0;
225+
}, [segmentInfo.segments]);
170226

171227
// 简化的蜿蜒路径布局(考虑分段间距)
172228
const getNodePosition = useCallback((index: number) => {
@@ -184,30 +240,47 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
184240

185241
const xPercent = (xPixel / containerWidth) * 100;
186242

187-
// 计算Y位置,考虑分段间距(宝箱节点)
188-
const segmentIndex = getSegmentIndex(index);
243+
// 计算Y位置
244+
// 找出当前节点属于哪个分段,以及之前有多少个完整分段(即多少个宝箱)
245+
let treasureCount = 0;
246+
for (let i = 0; i < segmentInfo.segments.length; i++) {
247+
const seg = segmentInfo.segments[i];
248+
if (index > seg.endIndex) {
249+
// 这个分段已经完全在当前节点之前,且不是最后一个分段,所以有一个宝箱
250+
if (i < segmentInfo.segments.length - 1) {
251+
treasureCount++;
252+
}
253+
}
254+
}
255+
189256
const baseY = index * NODE_SPACING + 100;
190-
const segmentGapOffset = segmentIndex * SEGMENT_GAP;
191-
const yPosition = baseY + segmentGapOffset;
257+
const treasureSpaceOffset = treasureCount * SEGMENT_GAP;
258+
const yPosition = baseY + treasureSpaceOffset;
192259

193260
return { xPercent, xPixel, yPosition, index };
194-
}, [containerWidth, getSegmentIndex]);
261+
}, [containerWidth, segmentInfo.segments]);
195262

196-
// 获取宝箱节点位置 - 在分段间隙中居中
263+
// 获取宝箱节点位置 - 在分段末尾节点和下一分段首节点之间的中点
197264
const getTreasurePosition = useCallback((segmentIndex: number) => {
198-
const lastNodeIndex = (segmentIndex + 1) * SEGMENT_SIZE - 1;
199-
const firstNodeOfNextSegment = (segmentIndex + 1) * SEGMENT_SIZE;
265+
const segment = segmentInfo.segments[segmentIndex];
266+
const nextSegment = segmentInfo.segments[segmentIndex + 1];
267+
268+
if (!segment || !nextSegment) {
269+
return { xPercent: 50, xPixel: containerWidth / 2, yPosition: 0 };
270+
}
200271

201-
const lastNodePos = getNodePosition(Math.min(lastNodeIndex, problems.length - 1));
202-
const nextNodePos = getNodePosition(Math.min(firstNodeOfNextSegment, problems.length - 1));
272+
// 计算分段末尾节点的位置(不包含宝箱偏移的原始位置)
273+
const lastNodeBaseY = segment.endIndex * NODE_SPACING + 100 + segmentIndex * SEGMENT_GAP;
274+
// 计算下一分段首节点的位置
275+
const nextNodeBaseY = nextSegment.startIndex * NODE_SPACING + 100 + (segmentIndex + 1) * SEGMENT_GAP;
203276

204-
// 宝箱放在两个节点的垂直中点
277+
// 宝箱在两者中间
205278
return {
206279
xPercent: 50,
207280
xPixel: containerWidth / 2,
208-
yPosition: (lastNodePos.yPosition + nextNodePos.yPosition) / 2
281+
yPosition: (lastNodeBaseY + nextNodeBaseY) / 2
209282
};
210-
}, [getNodePosition, problems.length, containerWidth]);
283+
}, [containerWidth, segmentInfo.segments]);
211284

212285
// 获取终点宝箱位置
213286
const getEndpointTreasurePosition = useCallback(() => {
@@ -412,6 +485,12 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
412485
const generateTreasureNodes = () => {
413486
const treasures: JSX.Element[] = [];
414487

488+
// 如果是筛选模式(题目数量与原始不同),不显示中间宝箱
489+
const originalProblems = allProblems || problems;
490+
if (problems.length !== originalProblems.length) {
491+
return treasures;
492+
}
493+
415494
segmentInfo.segments.forEach((segment, segmentIndex) => {
416495
// 跳过最后一个分段(因为最后有终点标记)
417496
if (segmentIndex === segmentInfo.segmentCount - 1) return;
@@ -640,8 +719,14 @@ const DuolingoPath: React.FC<DuolingoPathProps> = ({
640719
{generateTreasureNodes()}
641720
</div>
642721

643-
{/* 终点宝箱节点 - 替换原来的静态徽章 */}
722+
{/* 终点宝箱节点 - 替换原来的静态徽章,筛选模式下隐藏 */}
644723
{(() => {
724+
const originalProblems = allProblems || problems;
725+
// 筛选模式下不显示终点宝箱
726+
if (problems.length !== originalProblems.length) {
727+
return null;
728+
}
729+
645730
const endpointPos = getEndpointTreasurePosition();
646731
return (
647732
<div

src/data/animation-list.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"generatedAt": "2026-01-05T16:10:33.153Z",
2+
"generatedAt": "2026-01-05T16:34:41.616Z",
33
"count": 2,
44
"animations": {
55
"46": {

0 commit comments

Comments
 (0)