From e08763ec373e1d7195737a5208956754f0bf909f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:21:01 +0800 Subject: [PATCH 1/7] fix(search): distinguish starred and rating states --- web/src/features/skill/skill-card.tsx | 34 ++++++++++++++++++++++----- web/src/i18n/locales/en.json | 6 +++++ web/src/i18n/locales/zh.json | 6 +++++ web/src/pages/search.tsx | 1 + 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index c1b6a9d8..7c20e776 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -1,13 +1,22 @@ +import { useTranslation } from 'react-i18next' import type { SkillSummary } from '@/api/types' +import { useAuth } from '@/features/auth/use-auth' +import { useStar } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' interface SkillCardProps { skill: SkillSummary onClick?: () => void + highlightStarred?: boolean } -export function SkillCard({ skill, onClick }: SkillCardProps) { +export function SkillCard({ skill, onClick, highlightStarred = false }: SkillCardProps) { + const { t } = useTranslation() + const { isAuthenticated } = useAuth() + const { data: starStatus } = useStar(skill.id) + const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred + return (
-

- {skill.displayName} -

+
+

+ {skill.displayName} +

+ {showStarredBadge ? ( + + {t('skillCard.starred')} + + ) : null} +
@@ -40,14 +56,20 @@ export function SkillCard({ skill, onClick }: SkillCardProps) { - {skill.downloadCount} + {t('skillCard.downloads', { count: skill.downloadCount })} + + + + + + {t('skillCard.stars', { count: skill.starCount })} {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + {t('skillCard.rating', { score: skill.ratingAvg.toFixed(1), count: skill.ratingCount })} )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f10da9f..ee124489 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -415,6 +415,12 @@ "starred": "Starred", "star": "Star" }, + "skillCard": { + "starred": "Starred", + "downloads": "{{count}} downloads", + "stars": "{{count}} stars", + "rating": "Rating {{score}} ({{count}})" + }, "copyButton": { "copied": "Copied", "copy": "Copy" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ee3fd5af..69057f8d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -415,6 +415,12 @@ "starred": "已收藏", "star": "收藏" }, + "skillCard": { + "starred": "已收藏", + "downloads": "下载 {{count}}", + "stars": "收藏 {{count}}", + "rating": "评分 {{score}} ({{count}})" + }, "copyButton": { "copied": "已复制", "copy": "复制" diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 334c4cb8..c7e18186 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -127,6 +127,7 @@ export function SearchPage() {
handleSkillClick(skill.namespace, skill.slug)} />
From f90e404efc1e93cd4df864a97b158faf88929858 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:23:36 +0800 Subject: [PATCH 2/7] fix(skill): refine starred and rating visual states --- web/src/features/skill/skill-card.tsx | 14 ++++++++------ web/src/features/social/star-button.tsx | 5 +++-- web/src/features/social/use-star.ts | 4 ++-- web/src/i18n/locales/en.json | 5 +---- web/src/i18n/locales/zh.json | 5 +---- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 7c20e776..8cd1e162 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/features/auth/use-auth' import { useStar } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { Bookmark } from 'lucide-react' interface SkillCardProps { skill: SkillSummary @@ -11,10 +12,10 @@ interface SkillCardProps { highlightStarred?: boolean } -export function SkillCard({ skill, onClick, highlightStarred = false }: SkillCardProps) { +export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) { const { t } = useTranslation() const { isAuthenticated } = useAuth() - const { data: starStatus } = useStar(skill.id) + const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred return ( @@ -32,7 +33,8 @@ export function SkillCard({ skill, onClick, highlightStarred = false }: SkillCar {skill.displayName} {showStarredBadge ? ( - + + {t('skillCard.starred')} ) : null} @@ -56,20 +58,20 @@ export function SkillCard({ skill, onClick, highlightStarred = false }: SkillCar - {t('skillCard.downloads', { count: skill.downloadCount })} + {skill.downloadCount} - {t('skillCard.stars', { count: skill.starCount })} + {skill.starCount} {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - {t('skillCard.rating', { score: skill.ratingAvg.toFixed(1), count: skill.ratingCount })} + {skill.ratingAvg.toFixed(1)} )} diff --git a/web/src/features/social/star-button.tsx b/web/src/features/social/star-button.tsx index 0d6c06fd..f72ac481 100644 --- a/web/src/features/social/star-button.tsx +++ b/web/src/features/social/star-button.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { useStar, useToggleStar } from './use-star' -import { Star } from 'lucide-react' +import { Bookmark } from 'lucide-react' import { useAuth } from '@/features/auth/use-auth' interface StarButtonProps { @@ -34,10 +34,11 @@ export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonPro ) diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index f5ab55e1..d12331b6 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -31,11 +31,11 @@ async function toggleStar(skillId: number, starred: boolean): Promise { } } -export function useStar(skillId: number) { +export function useStar(skillId: number, enabled = true) { return useQuery({ queryKey: ['skills', skillId, 'star'], queryFn: () => getStarStatus(skillId), - enabled: !!skillId, + enabled: !!skillId && enabled, }) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ee124489..1f4cf42b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -416,10 +416,7 @@ "star": "Star" }, "skillCard": { - "starred": "Starred", - "downloads": "{{count}} downloads", - "stars": "{{count}} stars", - "rating": "Rating {{score}} ({{count}})" + "starred": "Starred" }, "copyButton": { "copied": "Copied", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 69057f8d..9e71b83e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -416,10 +416,7 @@ "star": "收藏" }, "skillCard": { - "starred": "已收藏", - "downloads": "下载 {{count}}", - "stars": "收藏 {{count}}", - "rating": "评分 {{score}} ({{count}})" + "starred": "已收藏" }, "copyButton": { "copied": "已复制", From bbec999d49d01d9c7a97f1406ea8123e8cd0f563 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:26:02 +0800 Subject: [PATCH 3/7] fix(search): make starred skills easier to spot --- web/src/features/skill/skill-card.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 8cd1e162..7f2b5872 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -32,14 +32,20 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard

{skill.displayName}

+ +
{showStarredBadge ? ( - - + + {t('skillCard.starred')} ) : null} +
- {skill.summary && ( @@ -60,10 +66,11 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard {skill.downloadCount} - - - - + + {skill.starCount} {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( From 2f1faa1397ea309aad878ed6937dfd7329adfa25 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:28:38 +0800 Subject: [PATCH 4/7] feat(search): allow unstar from skill cards --- web/src/features/skill/skill-card.tsx | 149 +++++++++++++++----------- web/src/i18n/locales/en.json | 6 +- web/src/i18n/locales/zh.json | 6 +- 3 files changed, 99 insertions(+), 62 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 7f2b5872..f9c5fb86 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' -import { useStar } from '@/features/social/use-star' +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 { Bookmark } from 'lucide-react' @@ -15,74 +17,101 @@ interface SkillCardProps { export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) { const { t } = useTranslation() const { isAuthenticated } = useAuth() + const [confirmOpen, setConfirmOpen] = useState(false) const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) + const toggleStarMutation = useToggleStar(skill.id) const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred + const handleStarredBadgeClick = (event: React.MouseEvent) => { + event.stopPropagation() + setConfirmOpen(true) + } + + const handleConfirmUnstar = async () => { + if (!starStatus?.starred) { + return + } + await toggleStarMutation.mutateAsync(starStatus.starred) + } + return ( - - {/* Hover gradient border effect */} -
+ <> + + {/* Hover gradient border effect */} +
-
-
-
-

- {skill.displayName} -

+
+
+
+

+ {skill.displayName} +

+
+
+ {showStarredBadge ? ( + + ) : null} + +
-
- {showStarredBadge ? ( - - - {t('skillCard.starred')} + + {skill.summary && ( +

+ {skill.summary} +

+ )} + +
+ {skill.latestVersion && ( + + v{skill.latestVersion} - ) : null} - + )} + + + + + {skill.downloadCount} + + + + {skill.starCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} + + )}
+ - {skill.summary && ( -

- {skill.summary} -

- )} - -
- {skill.latestVersion && ( - - v{skill.latestVersion} - - )} - - - - - {skill.downloadCount} - - - - {skill.starCount} - - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - - - - - {skill.ratingAvg.toFixed(1)} - - )} -
-
- + + ) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f4cf42b..69fa62af 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -416,7 +416,11 @@ "star": "Star" }, "skillCard": { - "starred": "Starred" + "starred": "Starred", + "starredAction": "Click to unstar", + "unstarTitle": "Remove from starred", + "unstarDescription": "Are you sure you want to remove \"{{name}}\" from your starred skills?", + "unstarConfirm": "Remove" }, "copyButton": { "copied": "Copied", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 9e71b83e..e00d28b0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -416,7 +416,11 @@ "star": "收藏" }, "skillCard": { - "starred": "已收藏" + "starred": "已收藏", + "starredAction": "点击取消收藏", + "unstarTitle": "取消收藏", + "unstarDescription": "确定要取消收藏技能“{{name}}”吗?", + "unstarConfirm": "取消收藏" }, "copyButton": { "copied": "已复制", From 57c4f77d25e9d51967263114ba9d1386b92fadc7 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:33:52 +0800 Subject: [PATCH 5/7] feat(search): add starred-only filter --- web/src/api/types.ts | 1 + web/src/app/layout.tsx | 4 +- web/src/app/router.tsx | 1 + web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/zh.json | 4 + web/src/pages/home.tsx | 8 +- web/src/pages/landing.tsx | 4 +- web/src/pages/search.tsx | 104 ++++++++++++++++++---- web/src/pages/skill-detail.tsx | 2 +- web/src/shared/hooks/use-skill-queries.ts | 4 +- 10 files changed, 109 insertions(+), 27 deletions(-) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 63ad78bb..5f0b908a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -163,6 +163,7 @@ export interface SearchParams { sort?: string page?: number size?: number + starredOnly?: boolean } export interface PagedResponse { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 446d74a4..663dea2b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -40,7 +40,7 @@ export function Layout() {
-
@@ -97,7 +97,7 @@ export function HomePage() {
diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index 201cf014..ebd4def6 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -112,7 +112,7 @@ export function LandingPage() { }, []) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0 } }) + navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0, starredOnly: false } }) } const features = [ @@ -216,7 +216,7 @@ export function LandingPage() { diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index c7e18186..50db12b3 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,22 +1,52 @@ import { startTransition, useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import type { SkillSummary } from '@/api/types' +import { useAuth } from '@/features/auth/use-auth' import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' import { SkeletonList } from '@/shared/components/skeleton-loader' import { EmptyState } from '@/shared/components/empty-state' import { Pagination } from '@/shared/components/pagination' -import { useSearchSkills } from '@/shared/hooks/use-skill-queries' +import { useMyStars, useSearchSkills } from '@/shared/hooks/use-skill-queries' import { Button } from '@/shared/ui/button' +const PAGE_SIZE = 12 + +function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return skills + } + + return skills.filter((skill) => + [skill.displayName, skill.summary, skill.namespace, skill.slug] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(normalizedQuery)) + ) +} + +function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] { + const sorted = [...skills] + if (sort === 'downloads') { + return sorted.sort((left, right) => right.downloadCount - left.downloadCount) + } + if (sort === 'newest' || sort === 'relevance') { + return sorted.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()) + } + return sorted +} + export function SearchPage() { const { t } = useTranslation() const navigate = useNavigate() const searchParams = useSearch({ from: '/search' }) + const { isAuthenticated } = useAuth() const q = searchParams.q || '' const sort = searchParams.sort || 'relevance' const page = searchParams.page ?? 0 + const starredOnly = searchParams.starredOnly ?? false const [queryInput, setQueryInput] = useState(q) useEffect(() => { @@ -27,8 +57,10 @@ export function SearchPage() { q, sort, page, - size: 12, + size: PAGE_SIZE, + starredOnly, }) + const { data: starredSkills, isLoading: isLoadingStarred } = useMyStars(starredOnly && isAuthenticated) useEffect(() => { const normalizedQuery = queryInput.trim() @@ -38,41 +70,68 @@ export function SearchPage() { if (!normalizedQuery) { startTransition(() => { - navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 }) + navigate({ to: '/search', search: { q: '', sort, page: 0, starredOnly }, replace: page === 0 }) }) return } const timeoutId = window.setTimeout(() => { startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0, starredOnly }, replace: true }) }) }, 250) return () => window.clearTimeout(timeoutId) - }, [navigate, page, q, queryInput, sort]) + }, [navigate, page, q, queryInput, sort, starredOnly]) const handleSearch = (query: string) => { const normalizedQuery = query.trim() setQueryInput(query) startTransition(() => { - navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0, starredOnly }, replace: true }) }) } const handleSortChange = (newSort: string) => { - navigate({ to: '/search', search: { q, sort: newSort, page: 0 } }) + navigate({ to: '/search', search: { q, sort: newSort, page: 0, starredOnly } }) } const handlePageChange = (newPage: number) => { - navigate({ to: '/search', search: { q, sort, page: newPage } }) + navigate({ to: '/search', search: { q, sort, page: newPage, starredOnly } }) + } + + const handleStarredToggle = () => { + if (!isAuthenticated) { + navigate({ + to: '/login', + search: { + returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`, + }, + }) + return + } + + navigate({ to: '/search', search: { q, sort, page: 0, starredOnly: !starredOnly } }) } const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${slug}` }) } - const totalPages = data ? Math.ceil(data.total / data.size) : 0 + const filteredStarredSkills = starredOnly + ? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort) + : [] + const starredPageItems = starredOnly + ? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) + : [] + const totalPages = starredOnly + ? Math.ceil(filteredStarredSkills.length / PAGE_SIZE) + : data + ? Math.ceil(data.total / data.size) + : 0 + const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) + const isPageLoading = starredOnly ? isLoadingStarred : isLoading + const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) return (
@@ -107,23 +166,30 @@ export function SearchPage() { > {t('search.sort.newest')} +
- {data && data.total > 0 && ( + {resultCount > 0 && (
- {t('search.results', { count: data.total })} + {t('search.results', { count: resultCount })}
)}
{/* Results */} - {isLoading ? ( - - ) : data && data.items.length > 0 ? ( + {isPageLoading ? ( + + ) : displayItems.length > 0 ? ( <>
- {data.items.map((skill, idx) => ( + {displayItems.map((skill, idx) => (
) : ( )}
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index cd539d1d..41eb99d6 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -84,7 +84,7 @@ export function SkillDetailPage() { window.history.back() return } - navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } }) + navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } }) } if (isLoadingSkill) { diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 38d7d2e4..092a7382 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -86,6 +86,7 @@ export function useSearchSkills(params: SearchParams) { return useQuery({ queryKey: ['skills', 'search', params], queryFn: () => searchSkills(params), + enabled: params.starredOnly !== true, }) } @@ -128,10 +129,11 @@ export function useMySkills() { }) } -export function useMyStars() { +export function useMyStars(enabled = true) { return useQuery({ queryKey: ['skills', 'stars'], queryFn: getMyStars, + enabled, }) } From bf62b340abea2e8ea56bbf9b5c3b603c7c7e147f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:35:50 +0800 Subject: [PATCH 6/7] fix: change api/v1/cli to api/v1 --- docs/03-authentication-design.md | 8 ++++---- docs/05-business-flows.md | 6 +++--- docs/06-api-design.md | 12 ++++++------ .../iflytek/skillhub/controller/CliController.java | 2 +- .../skillhub/controller/DeviceAuthController.java | 2 +- .../controller/cli/CliPublishController.java | 2 +- .../skillhub/controller/CliControllerTest.java | 12 ++++++------ .../controller/DeviceAuthControllerTest.java | 6 +++--- .../iflytek/skillhub/auth/config/SecurityConfig.java | 6 +++--- .../skillhub/auth/token/ApiTokenScopeService.java | 8 ++++---- .../auth/token/ApiTokenAuthenticationFilterTest.java | 4 ++-- .../skillhub/auth/token/ApiTokenScopeFilterTest.java | 2 +- .../auth/token/ApiTokenScopeServiceTest.java | 2 +- web/src/api/generated/schema.d.ts | 12 ++++++------ 14 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index a8951b9e..a07bc69c 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -233,7 +233,7 @@ public class SecurityConfig { .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .ignoringRequestMatchers("/api/v1/cli/**")) + .ignoringRequestMatchers("/api/v1/**")) // ... ; } @@ -458,7 +458,7 @@ Session 中存储以下字段: - 后端设置 `XSRF-TOKEN` Cookie(`HttpOnly=false`) - 前端从 Cookie 读取 Token,放入请求 Header `X-XSRF-TOKEN` - 后端校验 Header 与 Cookie 是否一致 -- CLI API(`/api/v1/cli/**`)与兼容层(`/api/compat/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie) +- CLI API(`/api/v1/**`)与兼容层(`/api/compat/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie) ## 9. 前端权限控制 @@ -609,8 +609,8 @@ window.location.href = '/oauth2/authorization/github' | 接口 | 所需凭证 | 额外判定 | |------|---------|---------| -| `GET /api/v1/cli/whoami` | 任意有效 Bearer Token | 无 | -| `POST /api/v1/cli/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | +| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 | +| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 | ### 10.4 Admin API diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 1c5f8425..e01549c4 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -54,7 +54,7 @@ Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DR - 审核通过后才转为 `PUBLISHED` - 审核拒绝后转为 `REJECTED` - 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员 -- 上述例外必须对 Web、`/api/v1/cli/publish`、`/api/compat/v1/publish` 保持一致 +- 上述例外必须对 Web、`/api/v1/publish`、`/api/compat/v1/publish` 保持一致 ### 对象存储写入策略 @@ -67,7 +67,7 @@ Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DR ### CLI publish 请求规范 ``` -POST /api/v1/cli/publish +POST /api/v1/publish Content-Type: multipart/form-data Parts: - file: zip 包(必需) @@ -81,7 +81,7 @@ Phase 3 CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核。 如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。 Web 端可保留“发布后再提交审核”的两段式体验,但这属于 Phase 3 能力。 -`/api/v1/cli/publish` 响应: +`/api/v1/publish` 响应: ```json { diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 6e1da75c..6b6b5efc 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -239,7 +239,7 @@ Public API 的可见性规则: 发布状态约束: - 普通用户发布成功后,`status` 为 `PENDING_REVIEW` -- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/cli/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员 +- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员 ## 7.4 Token API(需登录) @@ -253,10 +253,10 @@ Public API 的可见性规则: | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/v1/cli/whoami` | 当前 Bearer Token 对应的用户信息 | -| POST | `/api/v1/cli/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) | -| GET | `/api/v1/cli/resolve/{namespace}/{slug}` | 解析版本 | -| GET | `/api/v1/cli/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 | +| GET | `/api/v1/whoami` | 当前 Bearer Token 对应的用户信息 | +| POST | `/api/v1/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) | +| GET | `/api/v1/resolve/{namespace}/{slug}` | 解析版本 | +| GET | `/api/v1/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 | ### ClawHub CLI 协议兼容层 @@ -266,7 +266,7 @@ Public API 的可见性规则: - 范围:一期聚焦覆盖 ClawHub CLI 所依赖的核心接口:查询、版本解析、下载、发布、whoami - 要求:兼容层优先保持 ClawHub CLI 既有请求/响应语义;若内部领域模型不同,通过 adapter 层完成协议转换,而不是要求客户端适配 skillhub 私有协议 - 要求:兼容层纳入 OpenAPI 或独立兼容协议文档,并作为正式对外契约维护 -- 要求:兼容层与 skillhub 自有 `/api/v1/cli/**` 并存,二者共享同一套权限、审计、限流与领域服务 +- 要求:兼容层与 skillhub 自有 `/api/v1/**` 并存,二者共享同一套权限、审计、限流与领域服务 - 非目标:前端页面不直接依赖兼容层;兼容层用于服务已有 ClawHub CLI 和相关自动化脚本 兼容层最少需要覆盖的能力类别: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java index 13d8071a..e24948c2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/CliController.java @@ -22,7 +22,7 @@ import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/v1/cli") +@RequestMapping("/api/v1") public class CliController extends BaseApiController { private final SkillPackageValidator skillPackageValidator; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java index 2b61b7c9..02f7d311 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/cli/auth/device") +@RequestMapping("/api/v1/auth/device") public class DeviceAuthController extends BaseApiController { private final DeviceAuthService deviceAuthService; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 73cbd1fa..cf915326 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -23,7 +23,7 @@ import java.io.IOException; import java.util.List; @RestController -@RequestMapping("/api/v1/cli") +@RequestMapping("/api/v1") public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 58d721ba..62ebe34d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -43,7 +43,7 @@ class CliControllerTest { @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { - mockMvc.perform(get("/api/v1/cli/whoami")) + mockMvc.perform(get("/api/v1/whoami")) .andExpect(status().isUnauthorized()); } @@ -66,7 +66,7 @@ class CliControllerTest { List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) ); - mockMvc.perform(get("/api/v1/cli/whoami").with(authentication(auth))) + mockMvc.perform(get("/api/v1/whoami").with(authentication(auth))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.msg").isNotEmpty()) @@ -88,7 +88,7 @@ class CliControllerTest { zipBytes ); - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + mockMvc.perform(multipart("/api/v1/check").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.valid").value(true)) @@ -107,7 +107,7 @@ class CliControllerTest { zipBytes ); - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + mockMvc.perform(multipart("/api/v1/check").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.valid").value(false)) @@ -125,7 +125,7 @@ class CliControllerTest { zipBytes ); - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + mockMvc.perform(multipart("/api/v1/check").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.valid").value(false)) @@ -142,7 +142,7 @@ class CliControllerTest { zipBytes ); - mockMvc.perform(multipart("/api/v1/cli/check").file(file)) + mockMvc.perform(multipart("/api/v1/check").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.valid").value(false)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java index f1ede80c..6523a754 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DeviceAuthControllerTest.java @@ -44,7 +44,7 @@ class DeviceAuthControllerTest { given(deviceAuthService.generateDeviceCode()).willReturn(response); - mockMvc.perform(post("/api/v1/cli/auth/device/code") + mockMvc.perform(post("/api/v1/auth/device/code") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) @@ -61,7 +61,7 @@ class DeviceAuthControllerTest { given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - mockMvc.perform(post("/api/v1/cli/auth/device/token") + mockMvc.perform(post("/api/v1/auth/device/token") .contentType(MediaType.APPLICATION_JSON) .content("{\"deviceCode\": \"device_abc123\"}")) .andExpect(status().isOk()) @@ -77,7 +77,7 @@ class DeviceAuthControllerTest { given(deviceAuthService.pollToken("device_abc123")).willReturn(response); - mockMvc.perform(post("/api/v1/cli/auth/device/token") + mockMvc.perform(post("/api/v1/auth/device/token") .contentType(MediaType.APPLICATION_JSON) .content("{\"deviceCode\": \"device_abc123\"}")) .andExpect(status().isOk()) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 889cd1cb..c9afab70 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -70,7 +70,7 @@ public class SecurityConfig { .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(csrfHandler) - .ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**") + .ignoringRequestMatchers("/api/v1/**", "/api/compat/**") ) .authorizeHttpRequests(auth -> auth .requestMatchers( @@ -81,8 +81,8 @@ public class SecurityConfig { "/api/v1/auth/session/bootstrap", "/api/v1/auth/direct/login", "/api/v1/auth/local/**", - "/api/v1/cli/auth/device/**", - "/api/v1/cli/check", + "/api/v1/auth/device/**", + "/api/v1/check", "/actuator/health", "/actuator/prometheus", "/v3/api-docs/**", diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java index 09445f69..d18571df 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeService.java @@ -19,9 +19,9 @@ public class ApiTokenScopeService { ScopeRule.allow(null, "/api/v1/health"), ScopeRule.allow(null, "/api/v1/auth/providers"), ScopeRule.allow(null, "/api/v1/auth/me"), - ScopeRule.allow(null, "/api/v1/cli/auth/device/**"), - ScopeRule.allow(null, "/api/v1/cli/check"), - ScopeRule.allow("GET", "/api/v1/cli/whoami"), + ScopeRule.allow(null, "/api/v1/auth/device/**"), + ScopeRule.allow(null, "/api/v1/check"), + ScopeRule.allow("GET", "/api/v1/whoami"), ScopeRule.allow("GET", "/api/v1/skills"), ScopeRule.allow("GET", "/api/v1/skills/**"), ScopeRule.allow("GET", "/api/v1/namespaces"), @@ -39,7 +39,7 @@ public class ApiTokenScopeService { ScopeRule.require(null, "/api/v1/tokens", "token:manage"), ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"), ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"), - ScopeRule.require("POST", "/api/v1/cli/publish", "skill:publish"), + ScopeRule.require("POST", "/api/v1/publish", "skill:publish"), ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish") ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index a981ffd1..0c76132e 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -58,7 +58,7 @@ class ApiTokenAuthenticationFilterTest { when(role.getCode()).thenReturn("SKILL_ADMIN"); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/api/v1/cli/whoami"); + request.setRequestURI("/api/v1/whoami"); request.addHeader("Authorization", "Bearer raw-token"); filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); @@ -84,7 +84,7 @@ class ApiTokenAuthenticationFilterTest { when(userAccountRepository.findById("user-2")).thenReturn(Optional.of(user)); MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/api/v1/cli/publish"); + request.setRequestURI("/api/v1/publish"); request.addHeader("Authorization", "Bearer raw-token"); filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index fd50db75..10e33853 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -59,7 +59,7 @@ class ApiTokenScopeFilterTest { ); SecurityContextHolder.getContext().setAuthentication(authentication); - MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/cli/publish"); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java index 70c73b2d..002ec8a7 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeServiceTest.java @@ -24,7 +24,7 @@ class ApiTokenScopeServiceTest { void authorizeShouldAllowCliWhoamiWithoutScope() { ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize( "GET", - "/api/v1/cli/whoami", + "/api/v1/whoami", Set.of() ); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 8107fca8..b1917563 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -308,7 +308,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/publish": { + "/api/v1/publish": { parameters: { query?: never; header?: never; @@ -324,7 +324,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/check": { + "/api/v1/check": { parameters: { query?: never; header?: never; @@ -340,7 +340,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/auth/device/token": { + "/api/v1/auth/device/token": { parameters: { query?: never; header?: never; @@ -356,7 +356,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/auth/device/code": { + "/api/v1/auth/device/code": { parameters: { query?: never; header?: never; @@ -916,7 +916,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/whoami": { + "/api/v1/whoami": { parameters: { query?: never; header?: never; @@ -932,7 +932,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/cli/resolve/{namespace}/{slug}": { + "/api/v1/resolve/{namespace}/{slug}": { parameters: { query?: never; header?: never; From d8ec0c4088acea31e38844cb25068e4443091b70 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:38:22 +0800 Subject: [PATCH 7/7] fix(search): separate starred filter from sort controls --- web/src/i18n/locales/en.json | 3 ++ web/src/i18n/locales/zh.json | 3 ++ web/src/pages/search.tsx | 82 +++++++++++++++++++----------------- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8003e48b..7304dd9b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -85,6 +85,9 @@ "search": { "title": "Search Skills", "placeholder": "Search skills...", + "filters": { + "label": "Filter:" + }, "sort": { "label": "Sort:", "relevance": "Relevance", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 4aec07c1..e45b549b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -85,6 +85,9 @@ "search": { "title": "搜索技能", "placeholder": "搜索技能...", + "filters": { + "label": "筛选:" + }, "sort": { "label": "排序:", "relevance": "相关性", diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 50db12b3..053669b9 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -140,47 +140,53 @@ export function SearchPage() {
- {/* Sort Selector */} -
-
- {t('search.sort.label')} -
- - - - + {/* Sort And Filters */} +
+
+
+ {t('search.sort.label')} +
+ + + +
+ + {resultCount > 0 && ( +
+ {t('search.results', { count: resultCount })} +
+ )}
- {resultCount > 0 && ( -
- {t('search.results', { count: resultCount })} -
- )} +
+ {t('search.filters.label')} + +
{/* Results */}