Merge pull request #28 from iflytek/feature/project-local

Polish search, favorites, publish, token, and auth UX
This commit is contained in:
yun-zhi-ztl 2026-03-14 05:39:34 -07:00 • committed by GitHub
commit 9bc16f1e13
27 changed files with 310 additions and 140 deletions

View file

@ -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

View file

@ -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
{

View file

@ -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 和相关自动化脚本
兼容层最少需要覆盖的能力类别:

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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))

View file

@ -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())

View file

@ -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/**",

View file

@ -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")
);

View file

@ -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());

View file

@ -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);

View file

@ -24,7 +24,7 @@ class ApiTokenScopeServiceTest {
void authorizeShouldAllowCliWhoamiWithoutScope() {
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
"GET",
"/api/v1/cli/whoami",
"/api/v1/whoami",
Set.of()
);

View file

@ -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;

View file

@ -163,6 +163,7 @@ export interface SearchParams {
sort?: string
page?: number
size?: number
starredOnly?: boolean
}
export interface PagedResponse<T> {

View file

@ -40,7 +40,7 @@ export function Layout() {
<nav className="hidden md:flex items-center gap-6">
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0 }}
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'text-primary' }}
>
@ -109,7 +109,7 @@ export function Layout() {
<li>
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0 }}
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
>
{t('nav.search')}

View file

@ -131,6 +131,7 @@ const searchRoute = createRoute({
q: (search.q as string) || '',
sort: (search.sort as string) || 'relevance',
page: Number(search.page) || 0,
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
}
},
})

View file

@ -1,57 +1,117 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SkillSummary } from '@/api/types'
import { useAuth } from '@/features/auth/use-auth'
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'
interface SkillCardProps {
skill: SkillSummary
onClick?: () => void
highlightStarred?: boolean
}
export function SkillCard({ skill, onClick }: 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<HTMLButtonElement>) => {
event.stopPropagation()
setConfirmOpen(true)
}
const handleConfirmUnstar = async () => {
if (!starStatus?.starred) {
return
}
await toggleStarMutation.mutateAsync(starStatus.starred)
}
return (
<Card
className="h-full p-5 cursor-pointer group relative overflow-hidden"
onClick={onClick}
>
{/* Hover gradient border effect */}
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
<>
<Card
className="h-full p-5 cursor-pointer group relative overflow-hidden"
onClick={onClick}
>
{/* Hover gradient border effect */}
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
{skill.displayName}
</h3>
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
</div>
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
{skill.displayName}
</h3>
</div>
<div className="flex items-center gap-2">
{showStarredBadge ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-primary/25 bg-primary/12 px-2.5 py-1 text-[11px] font-semibold text-primary shadow-sm transition-colors hover:bg-primary/18"
aria-label={t('skillCard.starred')}
title={t('skillCard.starredAction')}
onClick={handleStarredBadgeClick}
>
<Bookmark className="h-3.5 w-3.5 fill-current" />
{t('skillCard.starred')}
</button>
) : null}
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
</div>
</div>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
{skill.summary}
</p>
)}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{skill.latestVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
v{skill.latestVersion}
</span>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
{skill.summary}
</p>
)}
<span className="flex items-center gap-1">
<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}
</span>
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{skill.latestVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
v{skill.latestVersion}
</span>
)}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
<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.ratingAvg.toFixed(1)} ({skill.ratingCount})
{skill.downloadCount}
</span>
)}
<span
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}
aria-label={showStarredBadge ? t('skillCard.starred') : undefined}
>
<Bookmark className={`w-3.5 h-3.5 ${showStarredBadge ? 'fill-current' : ''}`} />
{skill.starCount}
</span>
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{skill.ratingAvg.toFixed(1)}
</span>
)}
</div>
</div>
</div>
</Card>
</Card>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('skillCard.unstarTitle')}
description={t('skillCard.unstarDescription', { name: skill.displayName })}
confirmText={t('skillCard.unstarConfirm')}
onConfirm={handleConfirmUnstar}
/>
</>
)
}

View file

@ -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
<Button
variant={starStatus.starred ? 'default' : 'outline'}
size="sm"
className="justify-between"
onClick={handleToggle}
disabled={toggleMutation.isPending}
>
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
<Bookmark className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
{starStatus.starred ? t('starButton.starred') : t('starButton.star')} ({starCount})
</Button>
)

View file

@ -31,11 +31,11 @@ async function toggleStar(skillId: number, starred: boolean): Promise<void> {
}
}
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,
})
}

View file

@ -85,6 +85,9 @@
"search": {
"title": "Search Skills",
"placeholder": "Search skills...",
"filters": {
"label": "Filter:"
},
"sort": {
"label": "Sort:",
"relevance": "Relevance",
@ -94,6 +97,10 @@
},
"noResults": "No results found",
"noResultsFor": "No skills found matching \"{{q}}\"",
"filterStarred": "Starred only",
"noStarredResults": "No starred skills found",
"noStarredResultsFor": "No starred skills match \"{{q}}\"",
"noStarredSkills": "You have not starred any skills yet",
"enterKeyword": "Please enter a search keyword",
"results": "{{count}} skills found",
"resultCount": "Found <1>{{count}}</1> results"
@ -415,6 +422,13 @@
"starred": "Starred",
"star": "Star"
},
"skillCard": {
"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",
"copy": "Copy"

View file

@ -85,6 +85,9 @@
"search": {
"title": "搜索技能",
"placeholder": "搜索技能...",
"filters": {
"label": "筛选:"
},
"sort": {
"label": "排序:",
"relevance": "相关性",
@ -94,6 +97,10 @@
},
"noResults": "未找到结果",
"noResultsFor": "没有找到与 \"{{q}}\" 相关的技能",
"filterStarred": "只看已收藏",
"noStarredResults": "未找到已收藏技能",
"noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果",
"noStarredSkills": "你还没有收藏任何技能",
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果"
@ -415,6 +422,13 @@
"starred": "已收藏",
"star": "收藏"
},
"skillCard": {
"starred": "已收藏",
"starredAction": "点击取消收藏",
"unstarTitle": "取消收藏",
"unstarDescription": "确定要取消收藏技能“{{name}}”吗?",
"unstarConfirm": "取消收藏"
},
"copyButton": {
"copied": "已复制",
"copy": "复制"

View file

@ -21,7 +21,7 @@ export function HomePage() {
})
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 handleSkillClick = (namespace: string, slug: string) => {
@ -49,7 +49,7 @@ export function HomePage() {
</div>
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}>
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}>
{t('home.browseSkills')}
</Button>
<Button size="lg" variant="outline" onClick={() => navigate({ to: '/dashboard/publish' })}>
@ -67,7 +67,7 @@ export function HomePage() {
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0 } })}
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0, starredOnly: false } })}
>
{t('home.viewAll')}
</Button>
@ -97,7 +97,7 @@ export function HomePage() {
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0 } })}
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0, starredOnly: false } })}
>
{t('home.viewAll')}
</Button>

View file

@ -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() {
<Button
size="lg"
className="bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-white font-semibold px-8 py-6 text-lg rounded-xl shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 transition-all duration-300 hover:scale-105"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}
>
{t('landing.hero.exploreSkills')}
</Button>

View file

@ -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 (
<div className="space-y-8 animate-fade-up">
@ -81,52 +140,66 @@ export function SearchPage() {
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
</div>
{/* Sort Selector */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
<div className="flex gap-2">
<Button
variant={sort === 'relevance' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('relevance')}
>
{t('search.sort.relevance')}
</Button>
<Button
variant={sort === 'downloads' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('downloads')}
>
{t('search.sort.downloads')}
</Button>
<Button
variant={sort === 'newest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('newest')}
>
{t('search.sort.newest')}
</Button>
{/* Sort And Filters */}
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
<div className="flex gap-2">
<Button
variant={sort === 'relevance' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('relevance')}
>
{t('search.sort.relevance')}
</Button>
<Button
variant={sort === 'downloads' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('downloads')}
>
{t('search.sort.downloads')}
</Button>
<Button
variant={sort === 'newest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('newest')}
>
{t('search.sort.newest')}
</Button>
</div>
</div>
{resultCount > 0 && (
<div className="text-sm text-muted-foreground">
{t('search.results', { count: resultCount })}
</div>
)}
</div>
{data && data.total > 0 && (
<div className="text-sm text-muted-foreground">
{t('search.results', { count: data.total })}
</div>
)}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<Button
variant={starredOnly ? 'default' : 'outline'}
size="sm"
onClick={handleStarredToggle}
>
{t('search.filterStarred')}
</Button>
</div>
</div>
{/* Results */}
{isLoading ? (
<SkeletonList count={12} />
) : data && data.items.length > 0 ? (
{isPageLoading ? (
<SkeletonList count={PAGE_SIZE} />
) : displayItems.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{data.items.map((skill, idx) => (
{displayItems.map((skill, idx) => (
<div key={skill.id} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
<SkillCard
skill={skill}
highlightStarred
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
/>
</div>
@ -142,8 +215,12 @@ export function SearchPage() {
</>
) : (
<EmptyState
title={t('search.noResults')}
description={q ? t('search.noResultsFor', { q }) : t('search.enterKeyword')}
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
description={
starredOnly
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
}
/>
)}
</div>

View file

@ -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) {

View file

@ -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,
})
}