mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
fix(search): polish default sorting and loading states
This commit is contained in:
parent
091a5fe190
commit
cc3e45dc30
9 changed files with 78 additions and 29 deletions
|
|
@ -129,7 +129,7 @@ const searchRoute = createRoute({
|
|||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
q: (search.q as string) || '',
|
||||
sort: (search.sort as string) || 'relevance',
|
||||
sort: (search.sort as string) || 'newest',
|
||||
page: Number(search.page) || 0,
|
||||
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
|
|
@ -7,11 +8,12 @@ interface SearchBarProps {
|
|||
defaultValue?: string
|
||||
value?: string
|
||||
placeholder?: string
|
||||
isSearching?: boolean
|
||||
onChange?: (query: string) => void
|
||||
onSearch?: (query: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) {
|
||||
export function SearchBar({ defaultValue = '', value, placeholder, isSearching = false, onChange, onSearch }: SearchBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [query, setQuery] = useState(defaultValue)
|
||||
const isControlled = value !== undefined
|
||||
|
|
@ -37,32 +39,36 @@ export function SearchBar({ defaultValue = '', value, placeholder, onChange, onS
|
|||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
handleChange('')
|
||||
onSearch?.('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
value={currentQuery}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder || t('searchBar.placeholder')}
|
||||
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
/>
|
||||
{currentQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
aria-label={t('searchBar.clear')}
|
||||
title={t('searchBar.clear')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="px-8">
|
||||
{t('searchBar.button')}
|
||||
<Button type="submit" size="lg" className="px-8 min-w-28" disabled={isSearching}>
|
||||
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : t('searchBar.button')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useStar, useToggleStar } from '@/features/social/use-star'
|
|||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { Bookmark } from 'lucide-react'
|
||||
|
||||
interface SkillCardProps {
|
||||
|
|
@ -83,7 +84,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.downloadCount}
|
||||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}
|
||||
|
|
|
|||
|
|
@ -103,11 +103,13 @@
|
|||
"noStarredSkills": "You have not starred any skills yet",
|
||||
"enterKeyword": "Please enter a search keyword",
|
||||
"results": "{{count}} skills found",
|
||||
"resultCount": "Found <1>{{count}}</1> results"
|
||||
"resultCount": "Found <1>{{count}}</1> results",
|
||||
"loadingMore": "Updating search results..."
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "Search skills...",
|
||||
"button": "Search"
|
||||
"button": "Search",
|
||||
"clear": "Clear search"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login to SkillHub",
|
||||
|
|
|
|||
|
|
@ -103,11 +103,13 @@
|
|||
"noStarredSkills": "你还没有收藏任何技能",
|
||||
"enterKeyword": "请输入搜索关键词",
|
||||
"results": "找到 {{count}} 个技能",
|
||||
"resultCount": "找到 <1>{{count}}</1> 个结果"
|
||||
"resultCount": "找到 <1>{{count}}</1> 个结果",
|
||||
"loadingMore": "正在更新搜索结果..."
|
||||
},
|
||||
"searchBar": {
|
||||
"placeholder": "搜索技能...",
|
||||
"button": "搜索"
|
||||
"button": "搜索",
|
||||
"clear": "清空搜索"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 SkillHub",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@/shared/ui/button'
|
|||
import { Card } from '@/shared/ui/card'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { useMySkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
|
||||
export function MySkillsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -86,7 +87,7 @@ export function MySkillsPage() {
|
|||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.downloadCount}
|
||||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { SearchBar } from '@/features/search/search-bar'
|
||||
|
|
@ -44,7 +45,7 @@ export function SearchPage() {
|
|||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const q = searchParams.q || ''
|
||||
const sort = searchParams.sort || 'relevance'
|
||||
const sort = searchParams.sort || 'newest'
|
||||
const page = searchParams.page ?? 0
|
||||
const starredOnly = searchParams.starredOnly ?? false
|
||||
const [queryInput, setQueryInput] = useState(q)
|
||||
|
|
@ -53,14 +54,18 @@ export function SearchPage() {
|
|||
setQueryInput(q)
|
||||
}, [q])
|
||||
|
||||
const { data, isLoading } = useSearchSkills({
|
||||
const { data, isLoading, isFetching } = useSearchSkills({
|
||||
q,
|
||||
sort,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
starredOnly,
|
||||
})
|
||||
const { data: starredSkills, isLoading: isLoadingStarred } = useMyStars(starredOnly && isAuthenticated)
|
||||
const {
|
||||
data: starredSkills,
|
||||
isLoading: isLoadingStarred,
|
||||
isFetching: isFetchingStarred,
|
||||
} = useMyStars(starredOnly && isAuthenticated)
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = queryInput.trim()
|
||||
|
|
@ -131,13 +136,19 @@ export function SearchPage() {
|
|||
: 0
|
||||
const displayItems = starredOnly ? starredPageItems : (data?.items ?? [])
|
||||
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
|
||||
const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading
|
||||
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
|
||||
<SearchBar
|
||||
value={queryInput}
|
||||
isSearching={isUpdatingResults}
|
||||
onChange={setQueryInput}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort And Filters */}
|
||||
|
|
@ -177,6 +188,13 @@ export function SearchPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{isUpdatingResults ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t('search.loadingMore')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { StarButton } from '@/features/social/star-button'
|
|||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
|
@ -228,7 +229,7 @@ export function SkillDetailPage() {
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">{t('skillDetail.downloads')}</div>
|
||||
<div className="font-semibold text-foreground">{skill.downloadCount.toLocaleString()}</div>
|
||||
<div className="font-semibold text-foreground">{formatCompactCount(skill.downloadCount)}</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40" />
|
||||
|
|
|
|||
18
web/src/shared/lib/number-format.ts
Normal file
18
web/src/shared/lib/number-format.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function formatCompactCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000
|
||||
return `${stripTrailingZero(millions)}M`
|
||||
}
|
||||
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000
|
||||
return `${stripTrailingZero(thousands)}K`
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function stripTrailingZero(value: number): string {
|
||||
const formatted = value >= 10 ? value.toFixed(0) : value.toFixed(1)
|
||||
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue