Skip to content

Commit aca3fd5

Browse files
asdwclaude
andcommitted
fix: 修复 21 个问题 — 数据/逻辑/性能/无障碍/UX
数据/逻辑: - visitCount 阈值 1000→10000(万转换) - city-photo 按长度降序匹配(防短键屏蔽长键) - AI 空响应不加空气泡 - probeUrl no-cors 局限性注释 - DEFAULT_VIEWPORT 坐标顺序注释 - ErrorBoundary 3次重试限制 无障碍: - SortPopover/RegionSelect/Input/LocationCard/MobileLayout 补 aria-label - 按钮加 type=button 防误提交 - EmptyState 空标题不渲染 h3 - MapControls 未就绪 disabled 态 UX/性能: - Chat 桌面端加关闭按钮 + safe-area + 移动端FAB safe-area - Sidebar safe-area insets - LocationCard flyToMarker null 时 cursor-default - MarkersLayer setFeatureState 全 try-catch - promptShield 零宽字符注释化 - Worker replaceAll 子域名注释 - useFilteredLocations useMemo 空依赖注释 - MapContainer CJK 字体 PBF 注释 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 301fa90 commit aca3fd5

19 files changed

Lines changed: 140 additions & 45 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,4 +768,5 @@
768768

769769

770770

771+
771772

src/components/chat/ChatAssistant.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,19 @@ export function ChatAssistant() {
180180
abortRef.current = controller
181181
const response = await chat(chatMessages, controller.signal)
182182

183-
// 5. Add assistant message
184-
setMessages((prev) => [
185-
...prev,
186-
{
187-
id: generateId(),
188-
role: 'assistant',
189-
content: response,
190-
timestamp: new Date(),
191-
},
192-
])
183+
// 5. Add assistant message (skip if response is empty/whitespace)
184+
const trimmedResponse = response?.trim()
185+
if (trimmedResponse) {
186+
setMessages((prev) => [
187+
...prev,
188+
{
189+
id: generateId(),
190+
role: 'assistant',
191+
content: trimmedResponse,
192+
timestamp: new Date(),
193+
},
194+
])
195+
}
193196
setInput('')
194197
} catch (err: unknown) {
195198
const message =
@@ -335,8 +338,8 @@ export function ChatAssistant() {
335338

336339
return (
337340
<>
338-
{/* Injected keyframes */}
339-
<style>{KEYFRAMES}</style>
341+
{/* Injected keyframes — stable key prevents redundant DOM writes */}
342+
<style key="chat-keyframes">{KEYFRAMES}</style>
340343

341344
{/* ---------- Chat Panel ---------- */}
342345
<div
@@ -364,6 +367,7 @@ export function ChatAssistant() {
364367
WebkitBackdropFilter: 'blur(20px)',
365368
border: '1px solid var(--color-border)',
366369
boxShadow: 'var(--shadow-xl)',
370+
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
367371
}}
368372
>
369373
{/* ---- Header ---- */}
@@ -383,6 +387,14 @@ export function ChatAssistant() {
383387
动漫店铺问答
384388
</div>
385389
</div>
390+
<button
391+
type="button"
392+
onClick={closePanel}
393+
aria-label="关闭聊天面板"
394+
className="hidden md:flex h-7 w-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/35 active:scale-90 transition-all shrink-0"
395+
>
396+
<X size={16} color="#fff" />
397+
</button>
386398
</div>
387399

388400
{/* ---- Messages ---- */}
@@ -514,6 +526,7 @@ export function ChatAssistant() {
514526
color: '#ffffff',
515527
boxShadow: 'var(--shadow-lg)',
516528
animation: 'chat-pop-in 400ms var(--ease-spring) both',
529+
marginBottom: 'env(safe-area-inset-bottom, 0px)',
517530
}}
518531
onMouseEnter={(e) => {
519532
;(e.currentTarget as HTMLButtonElement).style.boxShadow =

src/components/layout/DesktopLayout.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ export function DesktopLayout({ locations }: DesktopLayoutProps) {
2323
<RouteLayer />
2424
</MapView>
2525
{/* 地图控件 */}
26-
<div className="absolute top-4 right-4 z-[1000]">
27-
<MapControls />
28-
</div>
26+
<MapControls className="absolute top-4 right-4 z-[1000]" />
2927
</div>
3028

3129
{/* 侧边栏浮在地图上方 */}

src/components/layout/MobileLayout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export function MobileLayout({ locations }: MobileLayoutProps) {
7474

7575
{/* 遮罩 — 点击关闭抽屉 */}
7676
<div
77+
role="dialog"
78+
aria-label="关闭菜单"
7779
className={cn(
7880
'fixed inset-0 z-40 bg-black/40 transition-opacity duration-300',
7981
sidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
@@ -105,6 +107,7 @@ export function MobileLayout({ locations }: MobileLayoutProps) {
105107
</span>
106108
</div>
107109
<button
110+
type="button"
108111
onClick={() => setSidebarOpen(false)}
109112
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 active:scale-90 transition-all"
110113
aria-label="关闭"

src/components/map/MapContainer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export function MapView({ children }: MapViewProps) {
4646
pixelRatio: isMobile ? 1 : window.devicePixelRatio,
4747
fadeDuration: 0,
4848
refreshExpiredTiles: false,
49+
// NOTE: localIdeographFontFamily depends on locally-installed CJK fonts.
50+
// PBF glyph fallback (localIdeographFontFamily: false) is disabled,
51+
// so missing CJK glyphs will render as tofu (□) on systems without
52+
// the specified fonts installed.
4953
localIdeographFontFamily: "'Noto Sans SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
5054
canvasContextAttributes: { antialias: false },
5155
trackResize: true,

src/components/map/MapControls.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface MapControlsProps {
88
}
99

1010
export function MapControls({ className }: MapControlsProps) {
11+
const isMapReady = useMapStore((s) => s.isMapReady)
1112
const zoomIn = useMapStore((s) => s.zoomIn)
1213
const zoomOut = useMapStore((s) => s.zoomOut)
1314
const flyToMarker = useMapStore((s) => s.flyToMarker)
@@ -24,6 +25,7 @@ export function MapControls({ className }: MapControlsProps) {
2425
'transition-all duration-300 ease-out',
2526
'hover:scale-110 active:scale-95',
2627
'hover:bg-white/95',
28+
!isMapReady && 'opacity-50 cursor-not-allowed',
2729
)
2830

2931
return (
@@ -37,20 +39,23 @@ export function MapControls({ className }: MapControlsProps) {
3739
>
3840
<button
3941
onClick={zoomIn}
42+
disabled={!isMapReady}
4043
className={cn(btnClass, 'rounded-t-2xl border-b border-gray-100/30')}
4144
aria-label="放大"
4245
>
4346
<Plus className="h-5 w-5" />
4447
</button>
4548
<button
4649
onClick={goHome}
50+
disabled={!isMapReady}
4751
className={cn(btnClass, 'border-b border-gray-100/30')}
4852
aria-label="回到初始位置"
4953
>
5054
<Home className="h-4 w-4" />
5155
</button>
5256
<button
5357
onClick={zoomOut}
58+
disabled={!isMapReady}
5459
className={cn(btnClass, 'rounded-b-2xl')}
5560
aria-label="缩小"
5661
>

src/components/map/MarkersLayer.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ function MarkersLayerInner({ locations }: MarkersLayerProps) {
3030
const popupRef = useRef<maplibregl.Popup | null>(null)
3131
const hoveredFeatureIdRef = useRef<number | null>(null)
3232
const settingUpRef = useRef(false)
33+
const willUnmountRef = useRef(false)
34+
35+
// Track actual unmount so we only tear down layers/source on final unmount,
36+
// not on every dependency change (e.g. locations data update).
37+
// MUST be declared BEFORE the main effect so its cleanup runs first on unmount.
38+
useEffect(() => {
39+
return () => {
40+
willUnmountRef.current = true
41+
}
42+
}, [])
3343

3444
// 字符串 location.id → 数字 feature index 映射
3545
const idToIndexMap = useMemo(() => {
@@ -125,19 +135,23 @@ function MarkersLayerInner({ locations }: MarkersLayerProps) {
125135
if (e.features && e.features[0]) {
126136
const fid = e.features[0].id as number
127137
if (hoveredFeatureIdRef.current === fid) return
128-
// 清除上一个
138+
// 清除上一个(try-catch 防止 source 在检查和调用之间被移除)
129139
if (hoveredFeatureIdRef.current !== null) {
130-
map.setFeatureState(
131-
{ source: 'locations', id: hoveredFeatureIdRef.current },
132-
{ hover: false }
133-
)
140+
try {
141+
map.setFeatureState(
142+
{ source: 'locations', id: hoveredFeatureIdRef.current },
143+
{ hover: false }
144+
)
145+
} catch (e) { if (e instanceof Error && !e.message.includes('does not exist')) console.warn('Feature state error:', e) }
134146
}
135147
// 设置新的
136148
hoveredFeatureIdRef.current = fid
137-
map.setFeatureState(
138-
{ source: 'locations', id: fid },
139-
{ hover: true }
140-
)
149+
try {
150+
map.setFeatureState(
151+
{ source: 'locations', id: fid },
152+
{ hover: true }
153+
)
154+
} catch (e) { if (e instanceof Error && !e.message.includes('does not exist')) console.warn('Feature state error:', e) }
141155
// 同步 store(侧边栏卡片高亮)
142156
const locId = e.features[0].properties?.id as string
143157
if (locId) {
@@ -278,7 +292,9 @@ function MarkersLayerInner({ locations }: MarkersLayerProps) {
278292
popupRef.current.remove()
279293
popupRef.current = null
280294
}
281-
// 清除图层和数据源(如果还存在)
295+
// Only remove layers/source on actual unmount, not on dependency changes.
296+
// On data updates, setupLayers() handles incremental updates via setData().
297+
if (!willUnmountRef.current) return
282298
try {
283299
if (map.getLayer('location-labels')) map.removeLayer('location-labels')
284300
if (map.getLayer('location-dots')) map.removeLayer('location-dots')
@@ -386,7 +402,7 @@ function renderPopupHTML(props: Record<string, unknown>): string {
386402
const visitHtml =
387403
visitCount != null && visitCount > 0
388404
? `<span style="font-size:11px;font-weight:600;color:var(--color-text, #1a1a2e);opacity:0.7;flex-shrink:0;">
389-
🔥 ${visitCount >= 1000 ? `${(visitCount / 10000).toFixed(1)}万` : visitCount}
405+
🔥 ${visitCount >= 10000 ? `${(visitCount / 10000).toFixed(1)}万` : visitCount}
390406
</span>`
391407
: ''
392408

src/components/sidebar/LocationCard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ export const LocationCard = memo(function LocationCard({ location, index }: Loca
4444
onMouseEnter={() => setHovered(location.id)}
4545
onMouseLeave={() => setHovered(null)}
4646
className={cn(
47-
'location-card rounded-[16px] bg-white p-3 cursor-pointer',
47+
'location-card rounded-[16px] bg-white p-3',
4848
'transition-all duration-300 ease-out',
4949
'hover:-translate-y-0.5 hover:shadow-card',
5050
'active:scale-[0.98]',
5151
isSelected && 'ring-2 ring-offset-1',
52-
isHovered && !isSelected && 'shadow-card'
52+
isHovered && !isSelected && 'shadow-card',
53+
flyToMarker ? 'cursor-pointer' : 'cursor-default'
5354
)}
5455
style={{
5556
boxShadow: isSelected
@@ -117,10 +118,12 @@ export const LocationCard = memo(function LocationCard({ location, index }: Loca
117118
{/* Navigate button — only show when selected */}
118119
{isSelected && (
119120
<button
121+
type="button"
120122
onClick={(e) => {
121123
e.stopPropagation()
122124
startNavigation(location)
123125
}}
126+
aria-label={`导航到${location.name}`}
124127
className="mt-2.5 flex w-full items-center justify-center gap-1.5 rounded-xl py-2 text-xs font-semibold
125128
transition-all duration-200 active:scale-[0.97]"
126129
style={{

src/components/sidebar/RegionSelect.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function RegionSelect({ regionList }: RegionSelectProps) {
6161
<div className="absolute top-full right-0 mt-1 z-50 min-w-[120px] max-h-[240px] overflow-y-auto rounded-xl glass border border-[var(--color-border)] shadow-elevated py-1">
6262
<button
6363
onClick={() => handleSelect(null)}
64+
aria-label="全部地区"
6465
className={cn(
6566
'w-full text-left px-3 py-1.5 text-[11px] transition-colors',
6667
'hover:bg-[var(--color-accent)]/5',
@@ -75,6 +76,7 @@ export function RegionSelect({ regionList }: RegionSelectProps) {
7576
<button
7677
key={region}
7778
onClick={() => handleSelect(region)}
79+
aria-label={region}
7880
className={cn(
7981
'w-full text-left px-3 py-1.5 text-[11px] transition-colors',
8082
'hover:bg-[var(--color-accent)]/5',

src/components/sidebar/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export function Sidebar({ locations, className }: SidebarProps) {
3838
transitionTimingFunction: sidebarCollapsed
3939
? 'cubic-bezier(0.4, 0, 0.2, 1)'
4040
: 'var(--ease-spring)',
41+
paddingTop: 'env(safe-area-inset-top, 0px)',
42+
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
4143
}}
4244
>
4345
{/* Header — compact one line */}

0 commit comments

Comments
 (0)