feat(web): unify landing and dashboard experience

Closes #824

Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-07 16:19:49 +08:00
parent 53df1041f5
commit 766303536a
57 changed files with 1975 additions and 922 deletions

View file

@ -76,6 +76,15 @@ public class NamespaceController extends BaseApiController {
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal)));
}
@GetMapping("/me/namespaces/page")
public ApiResponse<PageResponse<MyNamespaceResponse>> listMyNamespacesPage(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read",
namespacePortalQueryAppService.listMyNamespacesPage(pageable, userNsRoles, platformRoles(principal)));
}
@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute("userId") String userId,

View file

@ -103,6 +103,26 @@ public class NamespacePortalQueryAppService {
.toList();
}
@Transactional(readOnly = true)
public PageResponse<MyNamespaceResponse> listMyNamespacesPage(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<MyNamespaceResponse> empty = new PageImpl<>(List.of(), pageable, 0);
return PageResponse.from(empty);
}
Pageable sortedPageable = pageable.isPaged()
? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
org.springframework.data.domain.Sort.by("slug").ascending())
: PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("slug").ascending());
Page<MyNamespaceResponse> page = namespaceRepository
.findByIdIn(namespaceRoles.keySet().stream().toList(), sortedPageable)
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())));
return PageResponse.from(page);
}
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug,
String userId,

View file

@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -95,6 +96,26 @@ class NamespacePortalControllerTest {
.andExpect(jsonPath("$.data[0].canDelete").value(false));
}
@Test
void listMyNamespacesPage_limitsResultsAndReturnsTotal() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
given(namespaceRepository.findByIdIn(eq(List.of(1L)), any(org.springframework.data.domain.Pageable.class)))
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(namespace), PageRequest.of(0, 10), 1));
given(namespaceMemberRepository.findByUserId("owner-1"))
.willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER)));
mockMvc.perform(get("/api/v1/me/namespaces/page")
.param("page", "0")
.param("size", "10")
.with(auth("owner-1"))
.requestAttr("userId", "owner-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].slug").value("team-a"))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.size").value(10));
}
@Test
void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() throws Exception {
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());

View file

@ -13,6 +13,7 @@ public interface NamespaceRepository {
Optional<Namespace> findById(Long id);
List<Namespace> findAll();
List<Namespace> findByIdIn(List<Long> ids);
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Namespace save(Namespace namespace);

View file

@ -18,6 +18,7 @@ import java.util.Optional;
public interface NamespaceJpaRepository
extends JpaRepository<Namespace, Long>, NamespaceRepository {
List<Namespace> findByIdIn(List<Long> ids);
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
}

View file

@ -74,14 +74,14 @@ test.describe('Promotion review dashboard', () => {
}),
})
})
await page.route('**/api/web/me/namespaces', async (route) => {
await page.route('**/api/web/me/namespaces/page?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: [],
data: { items: [], total: 0, page: 0, size: 10 },
timestamp: new Date().toISOString(),
requestId: 'e2e-namespaces',
}),

View file

@ -25,19 +25,26 @@ test.describe('Light and dark theme', () => {
}),
})
})
await page.route('**/api/web/me/namespaces', async (route) => {
await page.route('**/api/web/me/namespaces/page?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'success',
data: [],
data: { items: [], total: 0, page: 0, size: 10 },
timestamp: '2026-09-01T00:00:00Z',
requestId: 'theme-namespace-fixture',
}),
})
})
await page.route('**/api/web/me/namespaces', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, msg: 'success', data: [], timestamp: '2026-09-01T00:00:00Z', requestId: 'theme-namespace-legacy-fixture' }),
})
})
await page.route('**/api/web/notifications/unread-count', async (route) => {
await route.fulfill({
status: 200,

View file

@ -1,7 +1,7 @@
{
"name": "skillhub-web",
"private": true,
"version": "0.1.0",
"version": "0.1.13",
"type": "module",
"packageManager": "pnpm@10.33.0",
"pnpm": {

View file

@ -660,6 +660,13 @@ export const namespaceApi = {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async listMinePage(params?: { page?: number; size?: number }): Promise<PagedResponse<ManagedNamespace>> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 10))
return fetchJson<PagedResponse<ManagedNamespace>>(`${WEB_API_PREFIX}/me/namespaces/page?${searchParams.toString()}`)
},
async getDetail(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
},

View file

@ -2964,6 +2964,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/me/namespaces/page": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyNamespacesPage"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/web/me/namespaces/page": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listMyNamespacesPage_1"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/namespaces": {
parameters: {
query?: never;
@ -5093,11 +5125,11 @@ export interface components {
/** Format: int32 */
size?: number;
};
ApiResponseListMyNamespaceResponse: {
ApiResponsePageResponseMyNamespaceResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["MyNamespaceResponse"][];
data?: components["schemas"]["PageResponseMyNamespaceResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
@ -5127,6 +5159,24 @@ export interface components {
canRestore?: boolean;
canDelete?: boolean;
};
PageResponseMyNamespaceResponse: {
items?: components["schemas"]["MyNamespaceResponse"][];
/** Format: int64 */
total?: number;
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
};
ApiResponseListMyNamespaceResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["MyNamespaceResponse"][];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
ApiResponseGovernanceSummaryResponse: {
/** Format: int32 */
code?: number;
@ -11240,6 +11290,50 @@ export interface operations {
};
};
};
listMyNamespacesPage: {
parameters: {
query: {
pageable: components["schemas"]["Pageable"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"];
};
};
};
};
listMyNamespacesPage_1: {
parameters: {
query: {
pageable: components["schemas"]["Pageable"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"];
};
};
};
};
listMyNamespaces: {
parameters: {
query?: never;

View file

@ -6,7 +6,7 @@ describe('getAppHeaderClassName', () => {
const className = getAppHeaderClassName(false)
expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
expect(className).toContain('bg-background/90')
expect(className).toContain('bg-background/70')
expect(className).not.toContain('bg-white')
})

View file

@ -1,10 +1,10 @@
import { cn } from '@/shared/lib/utils'
export const APP_HEADER_BASE_CLASS_NAME =
'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12'
'sticky top-0 z-50 flex items-center justify-between border-b border-border/30 bg-background/70 px-4 py-2.5 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/60 sm:px-6 md:px-12 min-h-[52px]'
export const APP_HEADER_ELEVATED_CLASS_NAME =
'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]'
'border-b border-border/30 shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]'
export function getAppHeaderClassName(isElevated: boolean): string {
return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME)

View file

@ -33,7 +33,7 @@ describe('getAppMainContentLayout', () => {
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
})
expect(layout.contentClassName).toContain('max-w-[1200px]')
expect(layout.contentClassName).toContain('max-w-[1100px]')
})
it('leaves other non-landing routes on the default full-width app content layout', () => {

View file

@ -1,8 +1,10 @@
export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10'
export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12'
export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20'
export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10'
export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1100px]'
export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const
interface AppMainContentLayout {
mainClassName: string
@ -31,7 +33,7 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout
}
}
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) {
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) {
return {
mainClassName: CENTERED_MAIN_CLASS_NAME,
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,

View file

@ -1,16 +1,22 @@
import { Suspense, useEffect, useRef, useState } from 'react'
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Menu, X } from 'lucide-react'
import { useAuth } from '@/features/auth/use-auth'
import { BrandMark } from '@/shared/components/brand-mark'
import { LanguageSwitcher } from '@/shared/components/language-switcher'
import { ThemeToggle } from '@/shared/components/theme-toggle'
import { UserMenu } from '@/shared/components/user-menu'
import { NotificationBell } from '@/features/notification/notification-bell'
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
import { syncDocumentLanguage } from '@/shared/lib/document-language'
import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard'
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { getAppHeaderClassName } from './layout-header-style'
import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content'
const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none'
/**
* Application shell shared by all routed pages.
*
@ -27,9 +33,19 @@ export function Layout() {
})
const { user, isLoading } = useAuth()
const [isHeaderElevated, setIsHeaderElevated] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const previousPathnameRef = useRef(pathname)
const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname)
const mainContentLayout = getAppMainContentLayout(contentLayoutPathname)
const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard')
const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/')
const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
const filteredDashboardGroups = SIDEBAR_GROUPS
.map((group) => ({
...group,
items: group.items.filter((item) => !item.admin || governanceVisible),
}))
.filter((group) => group.items.length > 0)
useEffect(() => {
syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language)
@ -72,6 +88,10 @@ export function Layout() {
const isActive = (to: string, exact?: boolean) => {
if (exact) return pathname === to
// 「控制台」按钮:仅在 /dashboard 主页或 /settings/* 时高亮
if (to === '/dashboard') {
return pathname === '/dashboard' || pathname.startsWith('/settings/')
}
// Keep matching strict so parent dashboard paths do not highlight unrelated child links.
return pathname === to
}
@ -91,11 +111,12 @@ export function Layout() {
{/* Header */}
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>
<Link to="/" className="text-xl font-semibold tracking-tight text-brand-gradient">
<Link to="/" className="text-xl font-semibold tracking-tight flex-shrink-0" style={{ color: 'hsl(var(--foreground))' }}>
SkillHub
</Link>
<nav className="hidden md:flex items-center gap-8 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
{/* Desktop nav — lg+ only */}
<nav className="hidden lg:flex items-center gap-5 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
{navItems.map((item) => {
if (item.auth && !user) return null
const active = isActive(item.to, item.exact)
@ -106,9 +127,10 @@ export function Layout() {
to={item.to}
className={
active
? 'px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm'
: 'hover:opacity-80 transition-opacity duration-150'
? 'px-4 py-1.5 rounded-full text-sm font-medium bg-foreground text-background shadow-[0_1px_2px_0_rgb(0_0_0/0.12)]'
: 'px-4 py-1.5 rounded-full text-sm font-medium hover:opacity-90 transition-opacity duration-150'
}
style={active ? undefined : { color: 'hsl(var(--foreground) / 0.65)' }}
>
{item.label}
</Link>
@ -116,7 +138,17 @@ export function Layout() {
})}
</nav>
<div className="flex items-center gap-2 text-[15px] font-normal sm:gap-3" style={{ color: 'hsl(var(--text-secondary))' }}>
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0" style={{ color: 'hsl(var(--text-secondary))' }}>
{/* Hamburger — visible below lg */}
<button
type="button"
className="lg:hidden inline-flex items-center justify-center rounded-lg p-2 hover:bg-accent transition-colors"
onClick={() => setMobileMenuOpen((prev) => !prev)}
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? '关闭导航菜单' : '打开导航菜单'}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<ThemeToggle />
<LanguageSwitcher />
{user && <NotificationBell />}
@ -133,104 +165,110 @@ export function Layout() {
</div>
</header>
{/* Mobile nav dropdown */}
{mobileMenuOpen ? (
<div className="lg:hidden sticky top-[52px] z-40 border-b border-border bg-background/95 backdrop-blur-xl">
<nav className="flex flex-col px-4 py-3 gap-1">
{navItems.map((item) => {
if (item.auth && !user) return null
const active = isActive(item.to, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
onClick={() => setMobileMenuOpen(false)}
>
{item.label}
</Link>
)
})}
</nav>
</div>
) : null}
{/* Main content */}
<main className={mainContentLayout.mainClassName}>
<Suspense
fallback={
<div className="space-y-4 animate-fade-up">
<div className="h-10 w-48 animate-shimmer rounded-lg" />
<div className="h-5 w-72 animate-shimmer rounded-md" />
<div className="h-64 animate-shimmer rounded-xl" />
<div className="space-y-3 animate-fade-up">
<div className="h-8 w-36 animate-shimmer rounded-md" />
<div className="h-4 w-56 animate-shimmer rounded-md" />
<div className="h-48 animate-shimmer rounded-lg" />
</div>
}
>
<div className={mainContentLayout.contentClassName}>
{showSidebar ? (
<div className="flex flex-col lg:flex-row gap-6">
<DashboardSidebar groups={filteredDashboardGroups} user={user} t={t} pathname={pathname} />
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
) : (
<Outlet />
</div>
)}
</div>
</Suspense>
</main>
{/* Footer */}
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
<div className="flex-shrink-0">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-sm bg-brand-gradient">
S
</div>
<span className="text-lg font-bold text-brand-gradient">SkillHub</span>
<footer className="relative z-10 mt-auto border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
<div className="mx-auto max-w-6xl px-6 py-12 md:px-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-5">
<div className="col-span-2 md:col-span-1">
<div className="mb-4 flex items-center gap-2.5">
<BrandMark className="h-8 w-8 rounded-lg bg-background ring-1 ring-border/70" />
<span className="font-semibold text-foreground">SkillHub</span>
</div>
<p className="text-sm max-w-xs" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('layout.footerDescription')}
</p>
<p className="text-sm text-muted-foreground">{t('layout.footerDescription')}</p>
</div>
<div className="flex flex-wrap gap-12 md:gap-16">
<div>
<h4 className="text-sm font-semibold mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{t('nav.home')}
</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('nav.home')}
</Link>
</li>
<li>
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="hover:opacity-80 transition-opacity"
style={{ color: 'hsl(var(--text-secondary))' }}
>
{t('nav.search')}
</Link>
</li>
<li>
<Link to="/dashboard" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('nav.dashboard')}
</Link>
</li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{t('footer.resources')}
</h4>
<ul className="space-y-2 text-sm">
<li>
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('footer.docs')}
</a>
</li>
<li>
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('footer.api')}
</a>
</li>
<li>
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('footer.community')}
</a>
</li>
</ul>
</div>
<div>
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.product')}</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><Link to="/search" search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }} className={FOOTER_LINK_CLASS_NAME}>{t('footer.marketplace')}</Link></li>
<li><Link to="/dashboard/publish" className={FOOTER_LINK_CLASS_NAME}>{t('footer.publish')}</Link></li>
<li><Link to="/dashboard" className={FOOTER_LINK_CLASS_NAME}>{t('nav.dashboard')}</Link></li>
</ul>
</div>
<div>
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.developers')}</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><a href="https://github.com/iflytek/skillhub/tree/main/docs/skillhub" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.docs')}</a></li>
<li><a href="/v3/api-docs" className={FOOTER_LINK_CLASS_NAME}>{t('footer.api')}</a></li>
<li><a href="https://github.com/iflytek/skillhub/tree/main/cli" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>CLI</a></li>
<li><a href="https://github.com/iflytek/skillhub" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>GitHub</a></li>
</ul>
</div>
<div>
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.project')}</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><a href="https://github.com/iflytek/skillhub/blob/main/LICENSE" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>License</a></li>
<li><a href="https://github.com/iflytek/skillhub/blob/main/CONTRIBUTING.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Contributing</a></li>
<li><a href="https://github.com/iflytek/skillhub/releases" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Changelog</a></li>
<li><a href="https://github.com/iflytek/skillhub/blob/main/CODE_OF_CONDUCT.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.codeOfConduct')}</a></li>
</ul>
</div>
<div>
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.resources')}</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><a href="https://discord.gg/qHYvtDNPHS" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Discord</a></li>
<li><a href="https://github.com/iflytek/skillhub/discussions" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.community')}</a></li>
<li><Link to="/privacy" className={FOOTER_LINK_CLASS_NAME}>{t('footer.privacy')}</Link></li>
<li><Link to="/terms" className={FOOTER_LINK_CLASS_NAME}>{t('footer.terms')}</Link></li>
</ul>
</div>
</div>
<div
className="mt-10 pt-6 border-t flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 text-xs"
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--muted-foreground))' }}
>
<div className="mt-10 border-t border-border/70 pt-7 text-xs text-muted-foreground">
<span>{t('footer.copyright')}</span>
<div className="flex items-center gap-2">
<Link to="/privacy" className="hover:opacity-80 transition-opacity">
{t('footer.privacy')}
</Link>
<span>|</span>
<Link to="/terms" className="hover:opacity-80 transition-opacity">
{t('footer.terms')}
</Link>
</div>
</div>
</div>
</footer>

View file

@ -20,12 +20,29 @@ const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.sear
// Export for use in cli-auth page
export { ORIGINAL_URL_SEARCH }
function RouteLoadingFallback() {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
)
}
function SilentRouteFallback() {
return <div className="min-h-[40vh]" aria-hidden />
}
interface LazyRouteOptions {
silentFallback?: boolean
}
function createLazyRouteComponent<TModule extends Record<string, unknown>>(
importer: () => Promise<TModule>,
exportName: keyof TModule,
options: LazyRouteOptions = {},
) {
// Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave
// consistently across public and dashboard pages.
// Most pages keep the visible route fallback. Dashboard-like tab pages can opt into a silent
// route fallback so their own local data skeleton remains the only loading state users see.
const LazyComponent = lazy(async () => {
const module = await importer().catch((error) => {
if (recoverFromDynamicImportError(error)) {
@ -41,13 +58,7 @@ function createLazyRouteComponent<TModule extends Record<string, unknown>>(
return function LazyRouteComponent(props: Record<string, unknown>) {
return (
<Suspense
fallback={
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
}
>
<Suspense fallback={options.silentFallback ? <SilentRouteFallback /> : <RouteLoadingFallback />}>
<LazyComponent {...props} />
</Suspense>
)
@ -58,9 +69,10 @@ function createRoleProtectedRouteComponent<TModule extends Record<string, unknow
importer: () => Promise<TModule>,
exportName: keyof TModule,
allowedRoles: readonly string[],
options: LazyRouteOptions = {},
) {
// Role checks stay at the route edge so page modules can assume the minimum permission level.
const RouteComponent = createLazyRouteComponent(importer, exportName)
const RouteComponent = createLazyRouteComponent(importer, exportName, options)
return function RoleProtectedRouteComponent(props: Record<string, unknown>) {
return (
@ -82,58 +94,70 @@ const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage')
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage', dashboardRouteOptions)
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage', dashboardRouteOptions)
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage', dashboardRouteOptions)
const MyNamespacesPage = createLazyRouteComponent(
() => import('@/pages/dashboard/my-namespaces'),
'MyNamespacesPage',
dashboardRouteOptions,
)
const NamespaceMembersPage = createLazyRouteComponent(
() => import('@/pages/dashboard/namespace-members'),
'NamespaceMembersPage',
dashboardRouteOptions,
)
const NamespaceReviewsPage = createLazyRouteComponent(
() => import('@/pages/dashboard/namespace-reviews'),
'NamespaceReviewsPage',
dashboardRouteOptions,
)
const NamespaceReviewDetailPage = createLazyRouteComponent(
() => import('@/pages/dashboard/review-detail'),
'NamespaceReviewDetailPage',
dashboardRouteOptions,
)
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage', dashboardRouteOptions)
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage', dashboardRouteOptions)
const ReviewProgressPage = createLazyRouteComponent(
() => import('@/pages/dashboard/review-progress'),
'ReviewProgressPage',
dashboardRouteOptions,
)
const ReportsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reports'),
'ReportsPage',
['SKILL_ADMIN', 'SUPER_ADMIN'],
dashboardRouteOptions,
)
const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage', dashboardRouteOptions)
const PromotionsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/promotions'),
'PromotionsPage',
['SKILL_ADMIN', 'SUPER_ADMIN'],
dashboardRouteOptions,
)
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage')
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage', dashboardRouteOptions)
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions)
const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions)
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
'SecuritySettingsPage',
dashboardRouteOptions,
)
const ProfileSettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/profile'),
'ProfileSettingsPage',
dashboardRouteOptions,
)
const NotificationSettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/notification-settings'),
'NotificationSettingsPage',
dashboardRouteOptions,
)
const AdminUsersPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/users'),

View file

@ -5,13 +5,11 @@ import type { ReportDisposition } from '@/api/types'
/**
* Loads reported skills for the requested moderation status.
*/
export function useSkillReports(status: string) {
export function useSkillReports(status: string, page = 0, size = 10) {
return useQuery({
queryKey: ['skill-reports', status],
queryFn: async () => {
const page = await reportApi.listSkillReports({ status })
return page.items
},
queryKey: ['skill-reports', status, page, size],
queryFn: () => reportApi.listSkillReports({ status, page, size }),
placeholderData: (previousData) => previousData,
})
}

View file

@ -171,7 +171,7 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
{version.status}
</span>
{isActiveReviewVersion(version, detail) ? (
<span className="inline-flex items-center rounded-full bg-brand-gradient px-2.5 py-0.5 text-xs font-medium text-white">
<span className="inline-flex items-center rounded-full bg-[#202020] px-2.5 py-0.5 text-xs font-medium text-white">
{t('review.activeReviewVersion')}
</span>
) : null}

View file

@ -52,7 +52,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
}
return (
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
<form onSubmit={handleSubmit} className="flex gap-2 rounded-lg border border-border/60 bg-card p-1.5 shadow-[var(--shadow-card)]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
@ -61,7 +61,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
onChange={(e) => handleChange(e.target.value)}
maxLength={MAX_SEARCH_INPUT_LENGTH}
placeholder={placeholder || t('searchBar.placeholder')}
className="pl-10 pr-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-10"
/>
{currentQuery ? (
<button
@ -75,7 +75,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
</button>
) : null}
</div>
<Button type="submit" size="lg" className="px-8 min-w-28" disabled={isSearching}>
<Button type="submit" size="default" className="px-5 min-w-24 rounded-md" disabled={isSearching}>
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : t('searchBar.button')}
</Button>
</form>

View file

@ -43,11 +43,13 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
const headlineVersion = getHeadlineVersion(skill)
const isInteractive = typeof onClick === 'function'
const complianceItems = skill.complianceSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
const downloadLabel = `下载 ${formatCompactCount(skill.downloadCount)}`
const starLabel = `收藏 ${skill.starCount}`
return (
<Card
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border-card))' }}
className="group relative h-full cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
style={{ borderColor: 'hsl(var(--border-card))', boxShadow: 'var(--shadow-card)' }}
onClick={onClick}
onKeyDown={(event) => {
if (!isInteractive) {
@ -65,7 +67,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
<div className="flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
{skill.displayName}
</h3>
</div>
@ -75,7 +77,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
</div>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed">
{skill.summary}
</p>
)}
@ -102,25 +104,27 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold" style={{ color: 'hsl(215 30% 35%)' }}>
v{headlineVersion.version}
</span>
)}
<span className="flex items-center gap-1">
<span className="flex items-center gap-1 text-muted-foreground" title={downloadLabel} aria-label={downloadLabel}>
<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>
{formatCompactCount(skill.downloadCount)}
</span>
<span
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}
title={starLabel}
aria-label={starLabel}
>
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? '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">
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400" title={`评分 ${skill.ratingAvg.toFixed(1)}`} aria-label={`评分 ${skill.ratingAvg.toFixed(1)}`}>
<svg className="w-3.5 h-3.5" 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)}

View file

@ -144,9 +144,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
<DialogContent>
{!createdToken ? (
<>
<DialogHeader className="text-center sm:text-center">
<DialogTitle className="text-center">{t('createToken.title')}</DialogTitle>
<DialogDescription className="text-center">
<DialogHeader className="text-center">
<DialogTitle>{t('createToken.title')}</DialogTitle>
<DialogDescription>
{t('createToken.description')}
</DialogDescription>
</DialogHeader>
@ -239,9 +239,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
</>
) : (
<>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{t('createToken.successTitle')}</DialogTitle>
<DialogDescription className="text-center break-words">
<DialogHeader className="min-w-0 text-center">
<DialogTitle>{t('createToken.successTitle')}</DialogTitle>
<DialogDescription className="break-words">
{t('createToken.successDescription')}
</DialogDescription>
</DialogHeader>

View file

@ -323,7 +323,8 @@
},
"dashboard": {
"title": "Dashboard",
"subtitle": "View your account, skills, and access credentials in one place",
"subtitle": "Manage your skills, account, and preferences",
"overview": "Overview",
"backToDashboard": "Back to Dashboard",
"userInfo": "Account Information",
"userInfoDesc": "Basic account details and platform roles",
@ -348,6 +349,37 @@
"previewMoreLabel": "View All",
"userId": "User ID"
},
"sidebar": {
"account": "Account",
"profile": "Profile",
"security": "Security",
"notifications": "Notifications",
"skillsAndData": "Skills & Data",
"mySkills": "My Skills",
"publish": "Publish",
"namespaces": "Namespaces",
"stars": "My Stars",
"subscriptions": "Subscriptions",
"tokens": "API Tokens",
"reviewProgress": "Review Progress",
"admin": "Admin",
"governance": "Governance",
"reports": "Reports"
},
"overview": {
"mySkills": "My Skills",
"mySkillsDesc": "View and manage all your published skills",
"publish": "Publish",
"publishDesc": "Upload and publish a new skill",
"tokens": "API Tokens",
"tokensDesc": "Manage your API access tokens",
"stars": "My Stars",
"starsDesc": "View your starred and rated skills",
"profile": "Profile",
"profileDesc": "Edit your display name and personal info",
"security": "Security",
"securityDesc": "Change password and account security options"
},
"mySkills": {
"title": "My Skills",
"subtitle": "Manage your published skills",
@ -1417,13 +1449,19 @@
}
},
"footer": {
"product": "Product",
"marketplace": "Skill marketplace",
"publish": "Publish skill",
"developers": "Developers",
"project": "Project",
"codeOfConduct": "Code of conduct",
"resources": "Resources",
"docs": "Documentation",
"api": "API",
"community": "Community",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"copyright": "© 2026 SkillHub. All rights reserved."
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"title": "Publish Skill",

View file

@ -323,7 +323,8 @@
},
"dashboard": {
"title": "Панель управления",
"subtitle": "Аккаунт, скиллы и учётные данные доступа — в одном месте",
"subtitle": "Управляйте скиллами, аккаунтом и настройками",
"overview": "Обзор",
"backToDashboard": "Назад к панели",
"userInfo": "Сведения об аккаунте",
"userInfoDesc": "Основные данные аккаунта и роли на платформе",
@ -348,6 +349,37 @@
"previewMoreLabel": "Смотреть все",
"userId": "ID пользователя"
},
"sidebar": {
"account": "Аккаунт",
"profile": "Профиль",
"security": "Безопасность",
"notifications": "Уведомления",
"skillsAndData": "Скиллы и данные",
"mySkills": "Мои скиллы",
"publish": "Публикация",
"namespaces": "Пространства",
"stars": "Мои звёзды",
"subscriptions": "Подписки",
"tokens": "Токены API",
"reviewProgress": "Статус проверки",
"admin": "Админ",
"governance": "Управление",
"reports": "Жалобы"
},
"overview": {
"mySkills": "Мои скиллы",
"mySkillsDesc": "Просмотр и управление опубликованными скиллами",
"publish": "Публикация",
"publishDesc": "Загрузить и опубликовать новый скилл",
"tokens": "Токены API",
"tokensDesc": "Управление токенами доступа к API",
"stars": "Мои звёзды",
"starsDesc": "Просмотр избранных и оценённых скиллов",
"profile": "Профиль",
"profileDesc": "Редактирование отображаемого имени и информации",
"security": "Безопасность",
"securityDesc": "Изменение пароля и параметров безопасности"
},
"mySkills": {
"title": "Мои скиллы",
"subtitle": "Управление опубликованными скиллами",
@ -1393,13 +1425,19 @@
"title": "Файлы"
},
"footer": {
"product": "Продукт",
"marketplace": "Каталог скиллов",
"publish": "Опубликовать скилл",
"developers": "Разработчикам",
"project": "Проект",
"codeOfConduct": "Кодекс поведения",
"resources": "Ресурсы",
"docs": "Документация",
"api": "API",
"community": "Сообщество",
"privacy": "Политика конфиденциальности",
"terms": "Условия использования",
"copyright": "© 2026 SkillHub. Все права защищены."
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"layout": {
"footerDescription": "Реестр скиллов: эффективное управление и распространение скиллов для разработчиков."

View file

@ -322,8 +322,9 @@
"goToLogin": "前往登录"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "统一查看账户信息、技能资产与访问凭证",
"title": "控制台",
"subtitle": "管理你的技能、账户与偏好设置",
"overview": "概览",
"backToDashboard": "返回控制台",
"userInfo": "用户信息",
"userInfoDesc": "查看当前账户的基础信息与平台角色",
@ -335,7 +336,7 @@
"viewSubscriptions": "查看我的订阅",
"mySkillsTitle": "我的技能",
"openMySkills": "查看我的技能",
"mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往“我的技能”查看全部。",
"mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往\u201C我的技能\u201D查看全部。",
"mySkillsPreviewEmpty": "你还没有发布任何技能",
"credentials": "访问凭证",
"openTokens": "查看 API Tokens",
@ -348,6 +349,37 @@
"previewMoreLabel": "查看全部",
"userId": "用户 ID"
},
"sidebar": {
"account": "账号管理",
"profile": "个人设置",
"security": "安全设置",
"notifications": "通知设置",
"skillsAndData": "技能与数据",
"mySkills": "我的技能",
"publish": "技能发布",
"namespaces": "命名空间",
"stars": "我的收藏",
"subscriptions": "我的订阅",
"tokens": "Token 凭证",
"reviewProgress": "我的审核进度",
"admin": "管理员",
"governance": "审核与治理",
"reports": "举报管理"
},
"overview": {
"mySkills": "我的技能",
"mySkillsDesc": "查看和管理你发布的所有技能",
"publish": "技能发布",
"publishDesc": "上传并发布新的技能到平台",
"tokens": "Token 凭证",
"tokensDesc": "管理 API 访问令牌",
"stars": "我的收藏",
"starsDesc": "查看你收藏和评分的技能",
"profile": "个人设置",
"profileDesc": "编辑你的展示名称和个人信息",
"security": "安全设置",
"securityDesc": "修改密码和账户安全选项"
},
"mySkills": {
"title": "我的技能",
"subtitle": "管理你发布的技能",
@ -1416,13 +1448,19 @@
}
},
"footer": {
"product": "产品",
"marketplace": "技能市场",
"publish": "发布技能",
"developers": "开发者",
"project": "项目",
"codeOfConduct": "行为准则",
"resources": "资源",
"docs": "文档",
"api": "API",
"community": "社区",
"privacy": "隐私政策",
"terms": "服务条款",
"copyright": "© 2026 SkillHub. 保留所有权利。"
"copyright": "© 2026 SkillHub. Apache-2.0."
},
"publish": {
"title": "发布技能",

View file

@ -4,80 +4,124 @@
@layer base {
:root {
/* SkillHub — 浅色主题 (indigo-violet brand) */
--background: 210 20% 98%;
--foreground: 220 26% 14%;
--card: 0 0% 100%;
--card-foreground: 220 26% 14%;
/*
SkillHub + 近黑ref: skillhub-clone, TabTin, skillhub.cn
纯白底冷灰卡片 #f2f3f5近黑按钮 #202020
无品牌色所有表达通过灰阶深度
*/
/* ── 基础层 — 纯白底,对标 skillhub-clone / skillhub.cn ── */
--background: 0 0% 100%; /* #ffffff */
--foreground: 0 0% 0%; /* #000000 文字 */
--card: 0 0% 100%; /* #ffffff 纯白卡片 */
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 220 26% 14%;
/* Indigo primary (#6A6DFF) */
--primary: 239 100% 71%;
--popover-foreground: 0 0% 0%;
/* ── 主色 — 近黑(无品牌色,对标 skillhub-clone #202020 ── */
--primary: 0 0% 13%; /* #202020 */
--primary-foreground: 0 0% 100%;
/* Surface tones */
--secondary: 210 30% 96%;
--secondary-foreground: 220 26% 14%;
--muted: 210 25% 95%;
--muted-foreground: 215 14% 46%;
/* Violet accent (#B85EFF) */
--accent: 271 100% 68%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 55%;
/* ── 表面层 — 冷灰递进 ── */
--secondary: 240 3% 97%; /* #f6f7f9 */
--secondary-foreground: 0 0% 0%;
--muted: 240 3% 94%; /* #eef0f2 */
--muted-foreground: 0 0% 45%; /* #737373保证辅助文字可读性 */
/* ── 强调色 — 淡蓝 hover对齐 TabTin hsl(214 70% 96%) ── */
--accent: 214 70% 96%; /* 淡蓝 hover */
--accent-foreground: 0 0% 7%; /* #111111 */
/* ── 功能色 ── */
--destructive: 0 55% 48%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 239 100% 71%;
--radius: 0.75rem;
--border: 0 0% 92%; /* rgba(0,0,0,0.08) */
--input: 240 3% 95%; /* #f2f3f5 */
--ring: 0 0% 13%; /* #202020 */
--radius: 0.625rem; /* 10px对标 skillhub-clone */
/* Extended palette */
--surface-glass: 210 30% 97%;
--glow-primary: 239 100% 71%;
--glow-accent: 271 100% 68%;
--success: 160 60% 45%;
--warning: 38 92% 58%;
/* ── 扩展色板 ── */
--surface-glass: 0 0% 100%;
--glow-primary: 0 0% 13%;
--glow-accent: 0 0% 7%;
--success: 160 50% 40%;
--warning: 38 85% 50%;
/* Brand */
--brand-start: #6A6DFF;
--brand-end: #B85EFF;
--brand-gradient: linear-gradient(135deg, #6A6DFF 0%, #B85EFF 100%);
/* ── 品牌渐变 — 无(纯黑) ── */
--brand-start: #202020;
--brand-end: #111111;
--brand-gradient: none;
/* Text semantic */
--text-secondary: 215 19% 35%;
--text-muted: 215 14% 46%;
--text-placeholder: 213 12% 63%;
/* ── 文字语义色 — 对标 clone 的 rgba(0,0,0,0.62) / 0.4 ── */
--text-secondary: 0 0% 45%; /* #737373正文辅助信息 */
--text-muted: 0 0% 38%;
--text-placeholder: 0 0% 45%;
/* Border semantic */
--border-card: 220 26% 94%;
/* ── 边框语义 ── */
--border-card: 0 0% 94%; /* rgba(0,0,0,0.06) */
/* ── 阴影 token — 极轻,对标 clone 的 0 1px 3px rgba(0,0,0,0.04) ── */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04);
--shadow-card-hover: 0 4px 12px 0 rgb(0 0 0 / 0.08);
--shadow-btn-primary: 0 1px 2px 0 rgb(0 0 0 / 0.06);
}
.dark {
/* Cool ink surfaces: distinct depth without near-black dead zones. */
--background: 222 30% 10%;
--foreground: 216 28% 93%;
--card: 222 25% 14%;
--card-foreground: 216 28% 93%;
--popover: 222 24% 15%;
--popover-foreground: 216 28% 93%;
--primary: 241 92% 74%;
--primary-foreground: 222 30% 10%;
--secondary: 222 20% 18%;
--secondary-foreground: 216 24% 88%;
--muted: 222 18% 19%;
--muted-foreground: 216 14% 66%;
--accent: 272 86% 72%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 52%;
--destructive-foreground: 0 0% 100%;
--border: 222 16% 25%;
--input: 222 16% 25%;
--ring: 241 92% 74%;
--surface-glass: 222 24% 14%;
--glow-primary: 241 92% 70%;
--glow-accent: 272 80% 68%;
--text-secondary: 216 17% 76%;
--text-muted: 216 14% 66%;
--text-placeholder: 216 12% 54%;
--border-card: 222 16% 24%;
/* ── 基础层 — 暗色保持近黑风格,但提高卡片对比度以改善 hover 可见性 ── */
--background: 0 0% 6%; /* #0f0f0f */
--foreground: 0 0% 94%; /* 更亮的文字 */
--card: 0 0% 10%; /* #1a1a1a 稍微亮一点 */
--card-foreground: 0 0% 94%;
--popover: 0 0% 11%;
--popover-foreground: 0 0% 94%;
/* ── 主色 — 白(暗色下反转) ── */
--primary: 0 0% 94%; /* 近白 */
--primary-foreground: 0 0% 6%; /* 近黑 */
/* ── 表面层 — 灰阶递进,提高对比度 ── */
--secondary: 0 0% 14%; /* 从 12% 提升 */
--secondary-foreground: 0 0% 90%;
--muted: 0 0% 18%; /* 从 15% 提升 */
--muted-foreground: 0 0% 68%; /* 从 60% 提升,图标更清晰 */
/* ── 强调色 — hover 更明显 ── */
--accent: 0 0% 22%; /* 从 18% 提升 */
--accent-foreground: 0 0% 85%;
/* ── 功能色 ── */
--destructive: 0 45% 48%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 20%; /* 从 18% 提升 */
--input: 0 0% 16%;
--ring: 0 0% 85%;
/* ── 扩展色板 ── */
--surface-glass: 0 0% 10%;
--glow-primary: 0 0% 80%;
--glow-accent: 0 0% 60%;
--success: 160 45% 42%;
--warning: 38 80% 48%;
/* ── 文字语义 — 提高可读性 ── */
--text-secondary: 0 0% 75%; /* 从 70% 提升 */
--text-muted: 0 0% 68%; /* 从 60% 提升 */
--text-placeholder: 0 0% 55%;
/* ── 边框 ── */
--border-card: 0 0% 18%; /* 从 16% 提升 */
/* ── 阴影 ── */
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.30);
--shadow-card-hover: 0 4px 12px 0 rgb(0 0 0 / 0.40);
--shadow-btn-primary: 0 1px 2px 0 rgb(0 0 0 / 0.20);
}
/* 暗色模式 card-hover */
.dark .card-hover:hover {
background: hsl(0 0% 20%);
border-color: hsl(0 0% 25%);
box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.30);
}
}
@ -92,11 +136,27 @@
body {
@apply bg-background text-foreground antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Text', 'PingFang SC', 'HarmonyOS Sans SC', 'Noto Sans SC', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Syne', 'IBM Plex Sans', system-ui, sans-serif;
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Display', 'PingFang SC', 'HarmonyOS Sans SC', system-ui, sans-serif;
font-weight: 500;
letter-spacing: -0.02em;
}
/* Hero 标题负 letter-spacing */
.hero-title {
letter-spacing: -0.02em;
font-weight: 500;
}
.page-title {
letter-spacing: -0.02em;
font-weight: 500;
}
code, pre, kbd {
@ -211,6 +271,25 @@
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.scroll-fade-up {
opacity: 1;
transform: none;
}
}
.animate-fade-in {
animation: fade-in 0.5s ease both;
}
@ -258,25 +337,12 @@
}
.dark .text-gradient-hero {
background: linear-gradient(135deg, #8183FF, #C77EFF);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
background: none;
-webkit-text-fill-color: unset;
color: hsl(var(--foreground));
}
/* ─── Card hover lift ─── */
.card-hover {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.1),
0 8px 16px -8px hsl(0 0% 0% / 0.2);
border-color: hsl(var(--primary) / 0.3);
}
/* ─── Card hover lift已移除由下方浅蓝灰 hover 替代) ─── */
/* ─── Scrollbar ─── */
::-webkit-scrollbar {
@ -478,8 +544,8 @@
}
.upload-zone:hover {
border-color: var(--brand-start);
background: rgba(106, 109, 255, 0.04);
border-color: #202020;
background: rgba(0, 0, 0, 0.02);
}
.upload-zone .upload-zone-icon {
@ -488,15 +554,140 @@
}
.upload-zone:hover .upload-zone-icon {
color: var(--brand-start);
color: #202020;
}
/* ─── Feature icon shadow ─── */
.feature-icon {
box-shadow: 0 14px 30px rgba(106, 109, 255, 0.45);
box-shadow: 0 14px 30px hsl(0 0% 0% / 0.12);
}
/* ─── Hero input placeholder ─── */
.hero-input::placeholder {
color: hsl(var(--text-placeholder));
}
/*
样式细节增强不改颜色只改交互/布局/字体
*/
/* ─── 胶囊按钮(白 + 近黑风格) ─── */
.btn-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding: 0.55rem 1.1rem;
font-size: 0.8125rem;
font-weight: 600;
transition: transform 160ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.btn-pill:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 3px;
}
.btn-pill-primary {
background: #202020;
color: #fff;
box-shadow: 0 1px 0 rgb(255 255 255 / 16%) inset, 0 4px 12px rgb(0 0 0 / 8%);
}
.btn-pill-primary:hover {
background: #111111;
transform: translateY(-1px);
box-shadow: 0 1px 0 rgb(255 255 255 / 16%) inset, 0 7px 18px rgb(0 0 0 / 12%);
}
.btn-pill-primary:active {
background: #000;
transform: translateY(0) scale(0.985);
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
}
.btn-pill-outline {
background: #f2f2f2;
color: #000;
border: 1px solid transparent;
box-shadow: none;
}
.btn-pill-outline:hover {
background: #e8e8e8;
border-color: rgb(0 0 0 / 6%);
transform: translateY(-1px);
}
.btn-pill-outline:active {
background: #dedede;
transform: translateY(0) scale(0.985);
}
@media (prefers-reduced-motion: reduce) {
.btn-pill,
.btn-pill:hover,
.btn-pill:active {
transform: none;
transition-duration: 0.01ms;
}
}
/* ─── 多彩标签(柔和彩色,不改品牌色) ─── */
.tag-color {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.tag-orange { background: hsl(30 70% 90%); color: hsl(25 70% 28%); }
.tag-purple { background: hsl(270 45% 92%); color: hsl(270 40% 32%); }
.tag-blue { background: hsl(215 60% 92%); color: hsl(215 55% 30%); }
.tag-green { background: hsl(155 50% 90%); color: hsl(155 55% 24%); }
.dark .tag-orange { background: hsl(25 45% 18%); color: hsl(30 70% 72%); }
.dark .tag-purple { background: hsl(270 30% 20%); color: hsl(270 45% 80%); }
.dark .tag-blue { background: hsl(215 40% 18%); color: hsl(215 50% 76%); }
.dark .tag-green { background: hsl(155 40% 16%); color: hsl(155 50% 68%); }
/* ─── Card hover对齐 TabTin hsl(214 70% 96%),无位移) ─── */
.card-hover {
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.card-hover:hover {
background: hsl(214 70% 96%);
box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.06);
border-color: hsl(214 50% 88%);
}
/* ─── Container / 选中项模型(淡蓝 tint对齐 TabTin ─── */
.container-tray {
background: hsl(214 70% 97%);
border-radius: 0.5rem;
padding: 0.25rem;
}
.container-tray .item-selected {
background: #fff;
border-radius: 0.375rem;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.04);
}
.container-tray .item-selected .text-foreground,
.container-tray .item-selected [style*="color: hsl(var(--foreground))"] {
color: hsl(var(--foreground)) !important;
}
.container-tray .item-idle {
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
}
.container-tray .item-idle:hover {
background: rgba(0,0,0,0.03);
}
/* 暗色模式 container-tray */
.dark .container-tray {
background: hsl(0 0% 13%);
}
.dark .container-tray .item-selected {
background: hsl(0 0% 18%);
border-color: rgba(255,255,255,0.08);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.30);
}
.dark .container-tray .item-idle:hover {
background: rgba(255,255,255,0.04);
}

View file

@ -62,16 +62,11 @@ describe('DashboardPage', () => {
expect(typeof DashboardPage).toBe('function')
})
it('renders the dashboard title and user info section', () => {
it('renders the dashboard sidebar and overview cards', () => {
const html = renderToStaticMarkup(<DashboardPage />)
expect(html).toContain('dashboard.title')
expect(html).toContain('dashboard.userInfo')
})
it('shows the my-skills preview section', () => {
const html = renderToStaticMarkup(<DashboardPage />)
expect(html).toContain('mySkills.title')
expect(html).toContain('sidebar.skillsAndData')
expect(html).toContain('overview.mySkills')
expect(html).not.toContain('overview.publish')
})
})

View file

@ -1,181 +1,211 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import type { SkillSummary } from '@/api/types'
import { useMySkills } from '@/shared/hooks/use-user-queries'
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
import { TokenList } from '@/features/token/token-list'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
import { limitPreviewItems } from './dashboard-preview'
const DASHBOARD_PREVIEW_LIMIT = 5
import {
Star, Heart, Package, Key, Shield, Flag, Globe,
UserCog, Lock, Bell, Clock, ChevronRight,
} from 'lucide-react'
/**
* Default dashboard landing page for authenticated users.
* Sidebar navigation groups for the Dashboard.
*
* It surfaces account context, quick links, and a lightweight preview of the user's latest skills
* and tokens before they move into more specialized dashboard sub-pages.
* Groups are rendered with a label divider; items within each group share visual spacing.
* Admin-only items are filtered based on the user's platform roles.
*/
interface SidebarItem {
key: string
icon: React.ComponentType<{ className?: string }>
label: string
to: string
admin?: boolean
badge?: string
}
interface SidebarGroup {
label?: string
items: SidebarItem[]
}
export const SIDEBAR_GROUPS: SidebarGroup[] = [
{
label: 'sidebar.account',
items: [
{ key: 'profile', icon: UserCog, label: 'sidebar.profile', to: '/settings/profile' },
{ key: 'security', icon: Lock, label: 'sidebar.security', to: '/settings/security' },
{ key: 'notifications', icon: Bell, label: 'sidebar.notifications', to: '/settings/notifications' },
],
},
{
label: 'sidebar.skillsAndData',
items: [
{ key: 'skills', icon: Package, label: 'sidebar.mySkills', to: '/dashboard/skills' },
{ key: 'namespaces', icon: Globe, label: 'sidebar.namespaces', to: '/dashboard/namespaces' },
{ key: 'stars', icon: Star, label: 'sidebar.stars', to: '/dashboard/stars' },
{ key: 'subscriptions', icon: Heart, label: 'sidebar.subscriptions', to: '/dashboard/subscriptions' },
{ key: 'tokens', icon: Key, label: 'sidebar.tokens', to: '/dashboard/tokens' },
{ key: 'reviewProgress', icon: Clock, label: 'sidebar.reviewProgress', to: '/dashboard/review-progress' },
],
},
{
label: 'sidebar.admin',
items: [
{ key: 'governance', icon: Shield, label: 'sidebar.governance', to: '/dashboard/governance', admin: true },
{ key: 'reports', icon: Flag, label: 'sidebar.reports', to: '/dashboard/reports', admin: true },
],
},
]
// Flatten for backward compatibility with layout.tsx
const SIDEBAR_NAV_ITEMS = SIDEBAR_GROUPS.flatMap((g) => g.items)
export const SIDEBAR_NAV = SIDEBAR_NAV_ITEMS.map(({ key, icon, label, to, admin }) => ({
key,
icon,
label,
to,
admin,
exact: false,
}))
/**
* Overview cards for the Dashboard home.
* Shown as a grid of quick-access cards linking to main sections.
*/
const OVERVIEW_CARDS = [
{ key: 'skills', icon: Package, label: 'overview.mySkills', to: '/dashboard/skills', desc: 'overview.mySkillsDesc' },
{ key: 'tokens', icon: Key, label: 'overview.tokens', to: '/dashboard/tokens', desc: 'overview.tokensDesc' },
{ key: 'stars', icon: Star, label: 'overview.stars', to: '/dashboard/stars', desc: 'overview.starsDesc' },
{ key: 'profile', icon: UserCog, label: 'overview.profile', to: '/settings/profile', desc: 'overview.profileDesc' },
{ key: 'security', icon: Lock, label: 'overview.security', to: '/settings/security', desc: 'overview.securityDesc' },
] as const
/**
* Dashboard home page with sidebar + overview cards.
*/
export function DashboardPage() {
const skillPreviewPageSize = DASHBOARD_PREVIEW_LIMIT
const { t } = useTranslation()
const { user } = useAuth()
const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
const { data: skillPage, isLoading: isLoadingSkills } = useMySkills({ page: 0, size: skillPreviewPageSize })
const skillPreview = limitPreviewItems<SkillSummary>(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT)
const filteredGroups = SIDEBAR_GROUPS
.map((group) => ({
...group,
items: group.items.filter((item) => !item.admin || governanceVisible),
}))
.filter((group) => group.items.length > 0)
return (
<div className={APP_SHELL_PAGE_CLASS_NAME}>
<div>
<h1 className="text-4xl font-bold" style={{ color: 'hsl(var(--foreground))' }}>{t('dashboard.title')}</h1>
<p className="mt-2 text-lg" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('dashboard.subtitle')}
</p>
</div>
{/* Page header — empty height placeholder for consistent spacing */}
<div className="pt-6 pb-1" />
<Card>
<CardHeader>
<CardTitle>{t('dashboard.userInfo')}</CardTitle>
<CardDescription>{t('dashboard.userInfoDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-5">
{user?.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-20 w-20 rounded-2xl border-2 border-border/60 shadow-card"
/>
)}
<div className="space-y-1.5">
<div className="text-xl font-semibold font-heading">{user?.displayName}</div>
<div className="text-sm text-muted-foreground">{user?.email}</div>
<div className="text-sm text-muted-foreground">{t('dashboard.userId')}: {user?.userId}</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
{t('dashboard.loginVia', { provider: user?.oauthProvider })}
</div>
</div>
</div>
{user?.platformRoles && user.platformRoles.length > 0 && (
<div className="space-y-3">
<div className="text-sm font-medium font-heading">{t('dashboard.platformRoles')}</div>
<div className="flex flex-wrap gap-2">
{user.platformRoles.map((role: string) => (
<span
key={role}
className="role-pill"
>
{role}
</span>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Two-column layout */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Left sidebar */}
<DashboardSidebar groups={filteredGroups} user={user} t={t} pathname="/dashboard" />
<div className={`grid grid-cols-1 gap-4 ${governanceVisible ? 'md:grid-cols-5' : 'md:grid-cols-4'}`}>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.starsAndRatings')}</div>
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewStars')}
</Link>
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.subscriptions')}</div>
<Link to="/dashboard/subscriptions" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewSubscriptions')}
</Link>
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.mySkillsTitle')}</div>
<Link to="/dashboard/skills" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.openMySkills')}
</Link>
</Card>
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.credentials')}</div>
<Link to="/dashboard/tokens" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.openTokens')}
</Link>
</Card>
{governanceVisible ? (
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.governanceTitle')}</div>
<Link to="/dashboard/governance" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewGovernance')}
</Link>
</Card>
) : null}
{governanceVisible ? (
<Card className="p-5">
<div className="text-sm text-muted-foreground">{t('dashboard.reportsTitle')}</div>
<Link to="/dashboard/reports" className="mt-2 inline-block font-semibold text-primary hover:underline">
{t('dashboard.viewReports')}
</Link>
</Card>
) : null}
</div>
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('mySkills.title')}</h2>
<Link to="/dashboard/skills" className="text-sm font-semibold text-primary hover:underline">
{t('dashboard.openMySkills')}
</Link>
</div>
<p className="text-sm text-muted-foreground">{t('dashboard.mySkillsPreviewDescription')}</p>
<Card>
<CardContent className="p-4">
{isLoadingSkills ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Array.from({ length: DASHBOARD_PREVIEW_LIMIT + 1 }).map((_, index) => (
<div key={index} className="h-20 animate-shimmer rounded-lg" />
))}
</div>
) : skillPreview.items.length > 0 ? (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{skillPreview.items.map((skill) => (
<Link
key={skill.id}
to="/space/$namespace/$slug"
params={{ namespace: skill.namespace, slug: skill.slug }}
className="rounded-lg border border-border/60 px-3 py-3 transition-colors hover:bg-accent/40"
>
<div className="truncate text-sm font-medium">{skill.displayName}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">@{skill.namespace}</div>
{getHeadlineVersion(skill) ? (
<div className="mt-2 inline-flex rounded-full bg-secondary px-2 py-1 text-xs text-muted-foreground">
v{getHeadlineVersion(skill)?.version}
</div>
) : null}
</Link>
))}
<Link
to="/dashboard/skills"
className="flex min-h-20 flex-col items-center justify-center rounded-lg border border-dashed border-border/70 px-3 py-3 text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
>
<span className="text-lg font-semibold leading-none">{t('dashboard.previewMore')}</span>
<span className="mt-2 text-xs font-medium">{t('dashboard.previewMoreLabel')}</span>
</Link>
{/* Right content - overview cards */}
<div className="flex-1 min-w-0">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{OVERVIEW_CARDS.map((card) => {
const Icon = card.icon
return (
<Link
key={card.key}
to={card.to}
className="group flex items-start gap-4 rounded-xl border border-border/60 p-5 transition-all duration-150 hover:bg-accent hover:border-border hover:shadow-sm"
>
<div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-secondary">
<Icon className="w-5 h-5" style={{ color: 'hsl(var(--foreground))' }} />
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">{t('dashboard.mySkillsPreviewEmpty')}</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<TokenList />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
{t(card.label)}
</span>
<ChevronRight className="w-3.5 h-3.5 opacity-0 -translate-x-1 transition-all duration-150 group-hover:opacity-100 group-hover:translate-x-0" style={{ color: 'hsl(var(--muted-foreground))' }} />
</div>
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
{t(card.desc)}
</p>
</div>
</Link>
)
})}
</div>
</div>
</div>
</div>
)
}
/** Reusable sidebar for dashboard and its sub-pages. */
export function DashboardSidebar({
groups,
user,
t,
pathname,
}: {
groups: SidebarGroup[]
user: ReturnType<typeof useAuth>['user']
t: ReturnType<typeof useTranslation>['t']
pathname: string
}) {
return (
<aside className="w-full lg:w-56 flex-shrink-0">
<div className="lg:sticky lg:top-[68px]">
{/* User summary */}
<div className="flex items-center gap-3 px-3 py-2 mb-4">
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} className="h-8 w-8 rounded-full border border-border/60" />
) : (
<div className="h-8 w-8 rounded-full bg-secondary flex items-center justify-center text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
{user?.displayName?.charAt(0) ?? '?'}
</div>
)}
<div className="min-w-0">
<div className="text-sm font-semibold truncate" style={{ color: 'hsl(var(--foreground))' }}>
{user?.displayName}
</div>
<div className="text-xs truncate" style={{ color: 'hsl(var(--muted-foreground))' }}>
{user?.email}
</div>
</div>
</div>
{/* Nav groups */}
{groups.map((group) => (
<div key={group.label ?? 'top'} className="mb-4">
{group.label && (
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'hsl(var(--muted-foreground))' }}>
{t(group.label)}
</div>
)}
<nav className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(item.to)
return (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
isActive ? 'bg-accent' : 'hover:bg-accent'
}`}
style={{ color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--text-secondary))' }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{t(item.label)}</span>
</Link>
)
})}
</nav>
</div>
))}
</div>
</aside>
)
}

View file

@ -76,6 +76,10 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }),
useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }),
useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }),
useMyNamespacesPage: () => ({
data: { items: mockNamespaces, total: mockNamespaces.length, page: 0, size: 10 },
isLoading: false,
}),
useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }),
useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }),
}))

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
@ -6,6 +6,7 @@ import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { EmptyState } from '@/shared/components/empty-state'
import { Pagination } from '@/shared/components/pagination'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
@ -13,7 +14,7 @@ import {
useArchiveNamespace,
useDeleteNamespace,
useFreezeNamespace,
useMyNamespaces,
useMyNamespacesPage,
useRestoreNamespace,
useUnfreezeNamespace,
} from '@/shared/hooks/use-namespace-queries'
@ -144,13 +145,24 @@ export async function executeNamespaceAction(
* namespace lifecycle actions because each action combines permissions, copy,
* and optimistic follow-up behavior that are specific to this route.
*/
const PAGE_SIZE = 10
export function MyNamespacesPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { hasRole } = useAuth()
const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const [pendingAction, setPendingAction] = useState<PendingNamespaceAction | null>(null)
const { data: namespaces, isLoading } = useMyNamespaces()
const [page, setPage] = useState(0)
const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE })
const namespaces = namespacePage?.items ?? []
const totalPages = namespacePage ? Math.max(Math.ceil(namespacePage.total / namespacePage.size), 1) : 1
useEffect(() => {
if (page >= totalPages) {
setPage(Math.max(totalPages - 1, 0))
}
}, [page, totalPages])
const freezeMutation = useFreezeNamespace()
const unfreezeMutation = useUnfreezeNamespace()
const archiveMutation = useArchiveNamespace()
@ -248,44 +260,43 @@ export function MyNamespacesPage() {
/>
{namespaces && namespaces.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{namespaces.map((namespace, idx) => (
<Card
key={namespace.id}
data-testid={`namespace-card-${namespace.slug}`}
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
className={`flex h-full flex-col p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleNamespaceClick(namespace.slug)}
>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold font-heading text-lg group-hover:text-primary transition-colors">
<div className="flex h-full flex-col gap-5">
<div className="flex flex-1 items-start">
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-3 flex flex-wrap items-center gap-2">
<h3 className="font-semibold font-heading text-lg leading-tight group-hover:text-primary transition-colors">
{namespace.displayName}
</h3>
<NamespaceBadge
type={namespace.type}
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
/>
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
{resolveStatusLabel(namespace.status)}
</span>
</div>
{namespace.description && (
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
{namespace.description}
</p>
)}
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
<div className="mt-3 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
<p className="mb-3 min-h-10 text-sm leading-relaxed text-muted-foreground">
{namespace.description ?? ''}
</p>
<div className="text-sm font-mono text-muted-foreground">@{namespace.slug}</div>
<div className="mt-4 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground">
{resolveHint(namespace.status, namespace.type)}
</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
<div className="mt-3 border-t border-border/50 pt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
{t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2 border-t border-border/50 pt-4">
{namespace.type === 'TEAM' && (
<Button
variant="outline"
@ -368,6 +379,10 @@ export function MyNamespacesPage() {
</Card>
))}
</div>
{totalPages > 1 ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
</>
) : (
<EmptyState
title={t('myNamespaces.emptyTitle')}

View file

@ -285,11 +285,6 @@ export function MySkillsPage() {
<DashboardPageHeader
title={t('mySkills.title')}
subtitle={t('mySkills.subtitle')}
actions={(
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishNew')}
</Button>
)}
/>
<div className="flex flex-col gap-3">
@ -506,11 +501,7 @@ export function MySkillsPage() {
<Button size="lg" variant="outline" onClick={handleClearSearch}>
{t('mySkills.clearSearch')}
</Button>
) : (
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
{t('mySkills.publishSkill')}
</Button>
)
) : undefined
}
/>
)}

View file

@ -155,36 +155,32 @@ export function PublishPage() {
}
return (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
<div className="mx-auto max-w-2xl space-y-8 animate-fade-up">
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
{prefill.resubmitSkill && prefill.resubmitVersion ? (
<Card className="border-amber-500/25 bg-amber-500/5 p-4">
<h2 className="text-sm font-semibold text-foreground">
{t('publish.resubmitNotice.title', {
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
version: prefill.resubmitVersion,
})}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('publish.resubmitNotice.description')}
</p>
</Card>
) : null}
<Card className="p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<Card className="space-y-8 p-6 md:p-8">
{prefill.resubmitSkill && prefill.resubmitVersion ? (
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-4">
<h2 className="text-sm font-semibold text-foreground">
{t('publish.resubmitNotice.title', {
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
version: prefill.resubmitVersion,
})}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('publish.resubmitNotice.description')}
</p>
</div>
) : null}
<div className="flex items-start gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<h3 className="text-sm font-semibold text-foreground mb-1">{t('publish.reviewNotice.title')}</h3>
<h3 className="mb-1 text-sm font-semibold text-foreground">{t('publish.reviewNotice.title')}</h3>
<p className="text-sm text-muted-foreground">{t('publish.reviewNotice.description')}</p>
</div>
</div>
</Card>
<Card className="p-8 space-y-8">
<div className="space-y-3">
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
{isLoadingNamespaces ? (

View file

@ -45,7 +45,7 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
vi.mock('@/features/report/use-skill-reports', () => ({
useDismissSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
useResolveSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSkillReports: () => ({ data: [], isLoading: false }),
useSkillReports: () => ({ data: { items: [], total: 0, page: 0, size: 10 }, isLoading: false }),
}))
vi.mock('@/features/report/report-text', () => ({

View file

@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -26,9 +27,15 @@ export function ReportsPage() {
disposition?: ReportDisposition
skillLabel: string
} | null>(null)
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING')
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED')
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED')
const [pages, setPages] = useState<Record<'PENDING' | 'RESOLVED' | 'DISMISSED', number>>({
PENDING: 0,
RESOLVED: 0,
DISMISSED: 0,
})
const pageSize = 10
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING', pages.PENDING, pageSize)
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED', pages.RESOLVED, pageSize)
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED', pages.DISMISSED, pageSize)
const resolveMutation = useResolveSkillReport()
const dismissMutation = useDismissSkillReport()
@ -77,13 +84,13 @@ export function ReportsPage() {
)
}
if (!reports || reports.length === 0) {
if (!reports || reports.items.length === 0) {
return <Card className="p-12 text-center text-muted-foreground">{t('reports.empty')}</Card>
}
return (
<div className="space-y-4">
{reports.map((report) => {
{reports.items.map((report) => {
const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}`
return (
<Card key={report.id} className="p-5 space-y-4">
@ -149,6 +156,13 @@ export function ReportsPage() {
</Card>
)
})}
{reports.total > reports.size ? (
<Pagination
page={pages[status]}
totalPages={Math.max(Math.ceil(reports.total / reports.size), 1)}
onPageChange={(nextPage) => setPages((current) => ({ ...current, [status]: nextPage }))}
/>
) : null}
</div>
)
}

View file

@ -199,6 +199,7 @@ function ProgressItem({
<Link
to="/space/$namespace/$slug"
params={{ namespace: item.namespace, slug: item.skillSlug }}
search={{ returnTo: '/dashboard/review-progress' }}
className="truncate font-semibold text-foreground underline-offset-4 hover:underline"
>
@{item.namespace}/{item.skillSlug}

View file

@ -6,6 +6,7 @@ import { SkeletonList } from '@/shared/components/skeleton-loader'
import { QuickStartSection } from '@/shared/components/quick-start'
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { BrandMark } from '@/shared/components/brand-mark'
import { Button } from '@/shared/ui/button'
export function HomePage() {
@ -35,7 +36,8 @@ export function HomePage() {
{/* Hero Section */}
<div className="text-center space-y-8 py-16 animate-fade-up">
<div className="space-y-4">
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold text-brand-gradient leading-tight">
<BrandMark className="mx-auto h-16 w-16 rounded-2xl bg-background shadow-sm ring-1 ring-border/70 md:h-20 md:w-20" />
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold leading-tight" style={{ color: 'hsl(var(--foreground))' }}>
SkillHub
</h1>
<p className="text-xl md:text-2xl max-w-2xl mx-auto" style={{ color: 'hsl(var(--text-secondary))' }}>
@ -52,7 +54,7 @@ export function HomePage() {
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
<button
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-brand-gradient shadow-sm hover:opacity-95 transition-opacity"
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-[#202020] shadow-sm hover:bg-[#111] transition-colors"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}
>
{t('home.browseSkills')}

View file

@ -16,12 +16,19 @@ vi.mock('react-i18next', async () => {
})
vi.mock('lucide-react', () => ({
ArrowRight: () => null,
CheckCircle2: () => null,
Clock3: () => null,
Copy: () => null,
PackageOpen: () => null,
Terminal: () => null,
Shield: () => null,
Users: () => null,
GitBranch: () => null,
Lock: () => null,
Monitor: () => null,
Search: () => null,
Server: () => null,
Settings: () => null,
}))
@ -68,6 +75,6 @@ describe('LandingPage', () => {
const html = renderToStaticMarkup(<LandingPage />)
expect(html).toContain('SkillHub')
expect(html).toContain('landing.hero.title')
expect(html).toContain('把团队的专业能力,沉淀成 Agent 可用的技能')
})
})

View file

@ -1,7 +1,22 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
import { PackageOpen, Terminal, Shield, Users, GitBranch, Search as SearchIcon, Settings } from 'lucide-react'
import {
ArrowRight,
CheckCircle2,
Clock3,
GitBranch,
Lock,
Monitor,
PackageOpen,
Search as SearchIcon,
Server,
Settings,
Shield,
Terminal,
Users,
} from 'lucide-react'
import { BrandMark } from '@/shared/components/brand-mark'
import { LandingQuickStartSection } from '@/shared/components/landing-quick-start'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
@ -9,6 +24,250 @@ import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { useInView } from '@/shared/hooks/use-in-view'
import { Button } from '@/shared/ui/button'
interface HeroSkillItem {
name: string
namespace: string
summary: string
version: string
badgeClassName: string
statusClassName: string
}
const HERO_SKILLS: HeroSkillItem[] = [
{
name: 'weather',
namespace: 'global',
summary: '查询全球城市实时天气',
version: '1.3.0',
badgeClassName: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300',
statusClassName: 'bg-emerald-500',
},
{
name: 'git-helper',
namespace: 'devtools',
summary: '智能 Git 操作辅助',
version: '2.1.0',
badgeClassName: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-300',
statusClassName: 'bg-emerald-500',
},
{
name: 'diagram-maker',
namespace: 'global',
summary: '生成流程图、架构图',
version: '0.9.2',
badgeClassName: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300',
statusClassName: 'bg-amber-500',
},
{
name: 'code-reviewer',
namespace: 'devtools',
summary: '自动代码审查',
version: '1.5.0',
badgeClassName: 'bg-cyan-50 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-300',
statusClassName: 'bg-emerald-500',
},
]
function HeroBrowserMockup({ onSearch }: { onSearch: (query: string) => void }) {
return (
<div className="relative mt-8 lg:mt-0">
<div className="absolute -inset-3 -z-10 hidden rotate-1 rounded-2xl bg-secondary lg:block" />
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-[var(--shadow-card)]">
<div className="flex items-center justify-between border-b border-border/70 bg-secondary/70 px-4 py-3">
<div className="flex items-center gap-2" aria-hidden>
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<span className="rounded-full bg-background px-3 py-1 text-[11px] font-medium text-muted-foreground ring-1 ring-border/70">
Skill Registry
</span>
</div>
<div className="flex min-h-[360px] flex-col p-5">
<div className="mb-4 flex items-center gap-2 rounded-lg border border-border/70 bg-secondary/70 px-3 py-2 transition-colors focus-within:border-ring focus-within:bg-background">
<SearchIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" strokeWidth={1.5} />
<input
type="text"
placeholder="搜索技能..."
className="min-w-0 flex-1 border-none bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSearch((event.target as HTMLInputElement).value)
}
}}
/>
</div>
<div className="space-y-2">
{HERO_SKILLS.map((skill) => (
<button
key={skill.name}
type="button"
className="group flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background p-3 text-left transition-[transform,border-color,background-color,box-shadow] duration-150 hover:-translate-y-px hover:border-border hover:bg-secondary/60 hover:shadow-sm active:translate-y-0 active:scale-[0.995] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none"
onClick={() => onSearch(skill.name)}
>
<span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold ${skill.badgeClassName}`}>
{skill.name.slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{skill.name}</span>
<span className="font-mono text-xs text-muted-foreground">@{skill.namespace}</span>
</span>
<span className="block truncate text-xs text-muted-foreground">{skill.summary}</span>
</span>
<span className="flex-shrink-0 text-right">
<span className={`mb-1 inline-block h-2 w-2 rounded-full ${skill.statusClassName}`} />
<span className="block font-mono text-xs text-muted-foreground">v{skill.version}</span>
</span>
</button>
))}
</div>
<div className="mt-auto flex items-center justify-between border-t border-border/70 pt-3">
<span className="text-xs text-muted-foreground"> 4 / 1000+ </span>
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary"
>
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
</div>
<div className="absolute -bottom-3 -right-3 hidden items-center gap-3 rounded-xl border border-border/70 bg-background p-3 shadow-lg sm:flex">
<BrandMark className="h-9 w-9 rounded-lg bg-background ring-1 ring-border/70" />
<div>
<div className="text-sm font-semibold text-foreground"></div>
<div className="text-xs text-muted-foreground">Web · CLI · Agent</div>
</div>
</div>
</div>
)
}
function LandingStatsSection() {
const stats = [
{ value: '500', suffix: '+', label: '已注册技能' },
{ value: '50', suffix: '+', label: '团队使用' },
{ value: '10', suffix: '万+', label: '累计下载' },
{ value: '99.9', suffix: '%', label: '服务可用性' },
]
return (
<section className="w-full border-y border-border/70 bg-background px-6 py-14 md:py-16">
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-8 md:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="mb-1 text-3xl font-semibold tracking-tight text-foreground md:text-4xl">
{stat.value}<span className="text-muted-foreground/45">{stat.suffix}</span>
</div>
<div className="text-sm text-muted-foreground">{stat.label}</div>
</div>
))}
</div>
</section>
)
}
function EnterpriseSection() {
return (
<section className="w-full bg-background px-6 py-16 md:py-20">
<div className="mx-auto max-w-6xl">
<div className="text-center mb-12">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Enterprise</p>
<h2 className="mb-3 text-2xl font-medium tracking-tight text-foreground md:text-3xl"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="md:col-span-2 md:row-span-2 flex flex-col rounded-xl border border-border/70 bg-card p-6 shadow-[var(--shadow-card)]">
<div className="mb-3 flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-900 text-white">
<Server className="h-4 w-4" />
</div>
<h3 className="font-semibold text-foreground"></h3>
</div>
<p className="mb-4 text-sm leading-relaxed text-muted-foreground">
Docker Kubernetes 线
</p>
<div className="flex-1 rounded-lg border border-border/70 bg-secondary/50 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex flex-col gap-1.5 flex-shrink-0">
<div className="mb-0.5 text-center text-[10px] text-muted-foreground"></div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Agent</div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">CLI</div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Web</div>
</div>
<div className="flex flex-1 items-center justify-center">
<svg className="h-4 w-16 text-border" fill="none" stroke="currentColor" viewBox="0 0 64 16"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" strokeDasharray="3 3" d="M0 8h54" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M50 4l6 4-6 4" /></svg>
</div>
<div className="flex flex-col items-center flex-shrink-0">
<div className="mb-0.5 text-center text-[10px] text-muted-foreground"></div>
<div className="rounded-lg bg-neutral-900 px-3.5 py-3 text-center text-xs font-semibold leading-tight text-white">SkillHub<br />Registry</div>
</div>
<div className="flex flex-1 items-center justify-center">
<svg className="h-4 w-16 text-border" fill="none" stroke="currentColor" viewBox="0 0 64 16"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" strokeDasharray="3 3" d="M0 8h54" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M50 4l6 4-6 4" /></svg>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<div className="mb-0.5 text-center text-[10px] text-muted-foreground"></div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">PostgreSQL</div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Redis</div>
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">MinIO / S3</div>
</div>
</div>
<div className="border-t border-dashed border-border/70 pt-2.5 text-center text-[10px] text-muted-foreground"> · </div>
</div>
</div>
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
<div className="mb-2 flex items-center gap-3">
<Shield className="h-5 w-5 text-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">线AI </p>
</div>
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
<div className="mb-2 flex items-center gap-3">
<Lock className="h-5 w-5 text-foreground" />
<h3 className="text-sm font-semibold text-foreground">RBAC </h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground"> RBACOwner / Admin / Member </p>
</div>
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
<div className="mb-2 flex items-center gap-3">
<Settings className="h-5 w-5 text-foreground" />
<h3 className="text-sm font-semibold text-foreground">SSO </h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">OAuth2 / SAML SSOAPI Token 访</p>
</div>
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
<div className="mb-2 flex items-center gap-3">
<Monitor className="h-5 w-5 text-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground"></p>
</div>
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
<div className="mb-2 flex items-center gap-3">
<GitBranch className="h-5 w-5 text-foreground" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">Apache-2.0 </p>
</div>
</div>
</div>
</section>
)
}
/**
* Marketing-style landing page for unauthenticated and first-time visitors.
*
@ -34,9 +293,9 @@ export function LandingPage() {
}
const heroView = useInView()
const statsView = useInView()
const featuresView = useInView()
const quickStartView = useInView()
const enterpriseView = useInView()
const popularView = useInView()
const latestView = useInView()
@ -50,187 +309,152 @@ export function LandingPage() {
const features = [
{
icon: <Shield className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.secure.title'),
description: t('landing.features.secure.description'),
icon: <Shield className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.secure.title', { defaultValue: '安全私密' }),
description: t('landing.features.secure.description', { defaultValue: '企业级安全保护您的 AI 工作流' }),
},
{
icon: <Users className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.community.title'),
description: t('landing.features.community.description'),
icon: <Users className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.community.title', { defaultValue: '社区驱动' }),
description: t('landing.features.community.description', { defaultValue: '与全球开发者分享和发现技能' }),
},
{
icon: <PackageOpen className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.integration.title'),
description: t('landing.features.integration.description'),
icon: <PackageOpen className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.integration.title', { defaultValue: '轻松集成' }),
description: t('landing.features.integration.description', { defaultValue: '无缝集成到您现有的工具中' }),
},
{
icon: <GitBranch className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.versionControl.title', { defaultValue: 'Version control' }),
description: t('landing.features.versionControl.description', { defaultValue: 'Managed release flows keep skill packages traceable and easier to review.' }),
icon: <GitBranch className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.versionControl.title', { defaultValue: '版本控制' }),
description: t('landing.features.versionControl.description', { defaultValue: '完善的版本管理和发布流程,确保技能包的质量和可追溯性。' }),
},
{
icon: <Terminal className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.cli.title', { defaultValue: 'CLI tooling' }),
description: t('landing.features.cli.description', { defaultValue: 'Command-line workflows support publishing, installing, and operating skills quickly.' }),
icon: <Terminal className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.cli.title', { defaultValue: 'CLI 工具' }),
description: t('landing.features.cli.description', { defaultValue: '强大的命令行工具,支持快速发布、安装和管理技能包。' }),
},
{
icon: <Settings className="w-6 h-6 text-white" strokeWidth={2} />,
title: t('landing.features.governance.title', { defaultValue: 'Governance' }),
description: t('landing.features.governance.description', { defaultValue: 'Built-in review and permission flows help teams enforce skill quality.' }),
icon: <Settings className="h-5 w-5" strokeWidth={1.75} />,
title: t('landing.features.governance.title', { defaultValue: '审核治理' }),
description: t('landing.features.governance.description', { defaultValue: '内置审核流程和权限管理,保障企业级技能质量。' }),
},
]
const stats = [
{ value: '1000+', label: t('landing.stats.skills', { defaultValue: 'Registry items' }) },
{ value: '50K+', label: t('landing.stats.downloads', { defaultValue: 'Downloads' }) },
{ value: '200+', label: t('landing.stats.teams', { defaultValue: 'Teams' }) },
const heroHighlights = [
{ icon: <PackageOpen className="h-4 w-4" strokeWidth={1.5} />, label: '1000+ 可用技能' },
{ icon: <Shield className="h-4 w-4" strokeWidth={1.5} />, label: '3 道安全审核' },
{ icon: <Terminal className="h-4 w-4" strokeWidth={1.5} />, label: '多平台接入' },
{ icon: <Clock3 className="h-4 w-4" strokeWidth={1.5} />, label: '版本可追溯' },
]
const popularTitle = t('home.popularTitle', { defaultValue: '热门下载' })
const popularDescription = t('home.popularDescription', { defaultValue: '社区最受欢迎的技能' })
const latestTitle = t('home.latestTitle', { defaultValue: '最新发布' })
const latestDescription = t('home.latestDescription', { defaultValue: '社区最新贡献的技能' })
const viewAllText = t('home.viewAll', { defaultValue: '查看全部 →' })
return (
<>
{/* Hero Section */}
<main ref={heroView.ref} className={`relative z-10 flex flex-col items-center pt-16 pb-20 px-4 md:pt-24 scroll-fade-up${heroView.inView ? ' in-view' : ''}`}>
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-brand-gradient mb-4">
SkillHub
</h1>
<h2
className="text-xl md:text-2xl font-semibold tracking-tight text-center mb-3"
style={{ color: 'hsl(var(--foreground))' }}
>
{t('landing.hero.title')}
</h2>
<p
className="text-base md:text-lg text-center max-w-2xl mb-10 leading-relaxed"
style={{ color: 'hsl(var(--text-secondary))' }}
>
{t('landing.hero.subtitle')}
</p>
{/* Search box */}
<div className="w-full max-w-2xl mb-8">
<div
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
style={{ borderColor: 'hsl(var(--border))' }}
>
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
<input
type="text"
placeholder={t('landing.hero.searchPlaceholder')}
className="hero-input flex-1 bg-transparent outline-none text-base"
style={{ color: 'hsl(var(--foreground))' }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearch((e.target as HTMLInputElement).value)
}
}}
/>
</div>
<main ref={heroView.ref} className={`relative z-10 w-full overflow-hidden px-6 py-16 scroll-fade-up${heroView.inView ? ' in-view' : ''} md:py-20`}>
<div className="absolute inset-0 -z-10">
<div className="absolute left-1/2 top-0 h-[400px] w-[900px] -translate-x-1/2 rounded-full bg-secondary blur-3xl" />
</div>
{/* CTA buttons */}
<div className="flex flex-wrap justify-center gap-4 mb-14">
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-brand-gradient shadow-sm hover:opacity-95 transition-opacity"
>
{t('landing.hero.exploreSkills')}
</Link>
<Link
to="/dashboard/publish"
className="px-8 py-3.5 rounded-xl text-base font-medium border transition-colors"
style={{
background: 'hsl(var(--secondary))',
borderColor: 'hsl(var(--muted-foreground))',
color: 'hsl(var(--muted-foreground))',
}}
>
{t('landing.hero.publishSkill', { defaultValue: '开始构建' })}
</Link>
</div>
{/* Stats */}
<div ref={statsView.ref} className={`flex flex-row justify-center gap-16 md:gap-24 scroll-fade-up${statsView.inView ? ' in-view' : ''}`} style={{ transitionDelay: '0.15s' }}>
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col items-center">
<span className="text-3xl md:text-4xl font-bold tracking-tight text-brand-gradient mb-1">
{stat.value}
</span>
<span className="text-sm font-normal" style={{ color: 'hsl(var(--foreground))' }}>
{stat.label}
</span>
<div className="mx-auto grid max-w-6xl grid-cols-1 items-start gap-12 lg:grid-cols-2 lg:items-center lg:gap-16">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border/70 bg-secondary/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
Agent
</div>
))}
<h1 className="mb-5 max-w-2xl text-4xl font-medium leading-[1.12] tracking-tight text-foreground md:text-5xl">
Agent
</h1>
<p className="mb-8 max-w-xl text-lg leading-relaxed text-muted-foreground">
AI
</p>
<div className="mb-8 flex flex-wrap gap-3">
<Link to="/dashboard/publish" className="btn-pill btn-pill-primary">
{t('landing.hero.publishSkill', { defaultValue: '发布技能' })}
</Link>
<Link
to="/search"
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
className="group btn-pill btn-pill-outline inline-flex items-center gap-2"
>
{t('landing.hero.exploreSkills', { defaultValue: '探索技能' })} <ArrowRight className="h-4 w-4 transition-transform duration-150 group-hover:translate-x-0.5 motion-reduce:transform-none" />
</Link>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-muted-foreground">
{heroHighlights.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className="text-muted-foreground">{item.icon}</span>
<span>{item.label}</span>
</div>
))}
</div>
</div>
<HeroBrowserMockup onSearch={handleSearch} />
</div>
</main>
{/* Features Section */}
<section ref={featuresView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${featuresView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{t('landing.whySkillHub.title', { defaultValue: '为什么选择 SkillHub' })}
<div className="mx-auto max-w-6xl px-6"><div className="h-px bg-border/70" /></div>
<section ref={featuresView.ref} className={`relative z-10 w-full bg-background px-6 py-16 scroll-fade-up${featuresView.inView ? ' in-view' : ''} md:py-20`}>
<div className="mx-auto max-w-6xl">
<div className="mb-12 max-w-3xl">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Capabilities</p>
<h2 className="mb-4 text-3xl font-medium tracking-tight text-foreground md:text-4xl">
</h2>
<p className="text-base md:text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('landing.whySkillHub.subtitle', { defaultValue: '专为企业打造的私有化 Agent 技能管理平台' })}
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 overflow-hidden rounded-2xl border border-border/70 bg-border/70 md:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<div
key={feature.title}
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">
<div key={feature.title} className="bg-card p-7 transition-colors hover:bg-secondary/50">
<div className="mb-5 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-foreground">
{feature.icon}
</div>
<h3 className="text-lg font-semibold text-center mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{feature.title}
</h3>
<p className="text-sm text-center leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
{feature.description}
</p>
<h3 className="mb-2 text-base font-semibold text-foreground">{feature.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Quick Start */}
<div ref={quickStartView.ref} className={`scroll-fade-up${quickStartView.inView ? ' in-view' : ''}`}>
<LandingQuickStartSection />
</div>
{/* Popular Downloads Section */}
<section ref={popularView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${popularView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<section ref={popularView.ref} className={`relative z-10 w-full bg-background px-6 py-16 scroll-fade-up${popularView.inView ? ' in-view' : ''} md:py-20`}>
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight mb-2" style={{ color: 'hsl(var(--foreground))' }}>
{t('home.popularTitle')}
</h2>
<p style={{ color: 'hsl(var(--text-secondary))' }}>{t('home.popularDescription')}</p>
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Marketplace</p>
<h2 className="mb-2 text-3xl font-medium tracking-tight text-foreground">{popularTitle}</h2>
<p className="text-sm text-muted-foreground">{popularDescription}</p>
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0, starredOnly: false } })}
>
{t('home.viewAll')}
{viewAllText}
</Button>
</div>
{isLoadingPopular ? (
<SkeletonList count={6} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
{popularSkills?.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
/>
<SkillCard skill={skill} onClick={() => handleSkillClick(skill.namespace, skill.slug)} />
</div>
))}
</div>
@ -238,39 +462,55 @@ export function LandingPage() {
</div>
</section>
{/* Latest Releases Section */}
<section ref={latestView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${latestView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<section ref={latestView.ref} className={`relative z-10 w-full border-y border-border/70 bg-secondary/70 px-6 py-16 scroll-fade-up${latestView.inView ? ' in-view' : ''} md:py-20`}>
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight mb-2" style={{ color: 'hsl(var(--foreground))' }}>
{t('home.latestTitle')}
</h2>
<p style={{ color: 'hsl(var(--text-secondary))' }}>{t('home.latestDescription')}</p>
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Latest</p>
<h2 className="mb-2 text-3xl font-medium tracking-tight text-foreground">{latestTitle}</h2>
<p className="text-sm text-muted-foreground">{latestDescription}</p>
</div>
<Button
variant="ghost"
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0, starredOnly: false } })}
>
{t('home.viewAll')}
{viewAllText}
</Button>
</div>
{isLoadingLatest ? (
<SkeletonList count={6} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
{latestSkills?.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
/>
<SkillCard skill={skill} onClick={() => handleSkillClick(skill.namespace, skill.slug)} />
</div>
))}
</div>
)}
</div>
</section>
<LandingStatsSection />
<div ref={enterpriseView.ref} className={`scroll-fade-up${enterpriseView.inView ? ' in-view' : ''}`}>
<EnterpriseSection />
</div>
<section className="relative z-10 w-full border-t border-border/70 bg-background px-6 py-16 text-center md:py-20">
<div className="mx-auto max-w-4xl">
<h2 className="mb-3 text-2xl font-medium tracking-tight text-foreground md:text-3xl"></h2>
<p className="mb-8 text-muted-foreground"> SkillHub</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a href="#quickstart" className="btn-pill btn-pill-primary">
</a>
<a href="/registry/skill.md" className="group btn-pill btn-pill-outline inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
</a>
</div>
</div>
</section>
</>
)
}

View file

@ -1,11 +1,15 @@
import { NotificationPreferenceForm } from '@/features/notification/notification-preference-form'
import { useTranslation } from 'react-i18next'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
/**
* Settings page for managing notification preferences at /settings/notifications.
*/
export function NotificationSettingsPage() {
const { t } = useTranslation()
return (
<div className="mx-auto max-w-2xl">
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('notification.preferences.title')} subtitle={t('notification.preferences.description')} />
<NotificationPreferenceForm />
</div>
)

View file

@ -9,6 +9,7 @@ import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
/** Regex matching allowed display name characters: Chinese, English, digits, spaces, underscore, hyphen. */
const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_ -]+$/
@ -175,7 +176,8 @@ export function ProfileSettingsPage() {
})
return (
<div className="mx-auto max-w-2xl">
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
<Card className="glass-strong">
<CardHeader className="flex flex-row items-center justify-between">
<div>
@ -184,7 +186,7 @@ export function ProfileSettingsPage() {
</div>
{!isEditing ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void navigate({ to: '/reset-password' })}>
<Button type="button" variant="outline" size="sm" onClick={() => void navigate({ to: '/settings/security' })}>
{t('profile.resetPassword')}
</Button>
{hasEditableFields ? (

View file

@ -10,6 +10,7 @@ import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
interface PasswordChangeCapabilityUser {
canChangePassword?: boolean
@ -87,7 +88,8 @@ export function SecuritySettingsPage() {
}
return (
<div className="mx-auto max-w-2xl">
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('security.title')} subtitle={t('security.subtitle')} />
<Card className="glass-strong">
<CardHeader>
<CardTitle>{t('security.title')}</CardTitle>

View file

@ -0,0 +1,23 @@
import { withBasePath } from '@/shared/lib/base-path'
import { cn } from '@/shared/lib/utils'
interface BrandMarkProps {
className?: string
imageClassName?: string
alt?: string
}
/**
* SkillHub public/favicon.svg
*/
export function BrandMark({ className, imageClassName, alt = 'SkillHub' }: BrandMarkProps) {
return (
<span className={cn('inline-flex items-center justify-center overflow-hidden rounded-xl', className)}>
<img
src={withBasePath('/favicon.svg')}
alt={alt}
className={cn('h-full w-full object-contain', imageClassName)}
/>
</span>
)
}

View file

@ -1,8 +1,3 @@
import { useNavigate } from '@tanstack/react-router'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
interface DashboardPageHeaderProps {
title: string
subtitle?: string
@ -11,21 +6,17 @@ interface DashboardPageHeaderProps {
/**
* Standard header used by dashboard sub-pages so navigation and page framing stay consistent.
*
* The "back to dashboard" link is intentionally omitted the sidebar already provides
* complete navigation and makes a dedicated back link redundant.
*/
export function DashboardPageHeader({ title, subtitle, actions }: DashboardPageHeaderProps) {
const { t } = useTranslation()
const navigate = useNavigate()
return (
<div className="space-y-4">
<Button variant="ghost" className="px-0 text-muted-foreground hover:text-foreground" onClick={() => navigate({ to: '/dashboard' })}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('dashboard.backToDashboard')}
</Button>
<div>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{title}</h1>
{subtitle ? <p className="text-muted-foreground text-lg">{subtitle}</p> : null}
<h1 className="text-2xl font-bold tracking-tight" style={{ color: 'hsl(var(--foreground))' }}>{title}</h1>
{subtitle ? <p className="mt-1 text-sm" style={{ color: 'hsl(var(--text-secondary))' }}>{subtitle}</p> : null}
</div>
{actions}
</div>

View file

@ -1,171 +1,385 @@
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Bot, Check, Copy, Terminal, UserRound } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useCopyToClipboard } from '@/shared/lib/clipboard'
import { useMemo, useState } from 'react'
import { Check, ChevronRight, Copy, Download, FileText, Search } from 'lucide-react'
import { buildApiUrl, WEB_API_PREFIX } from '@/api/client'
import { copyToClipboard, useCopyToClipboard } from '@/shared/lib/clipboard'
import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url'
import { toast } from '@/shared/lib/toast'
type LandingQuickStartTabId = 'agent' | 'human' | 'cli'
type AccessMode = 'agent' | 'cli' | 'web'
type AgentView = 'registry' | 'discovery'
interface LandingQuickStartTab {
id: LandingQuickStartTabId
label: string
interface AccessModeOption {
id: AccessMode
number: string
title: string
description: string
command: string
}
const tabIcons: Record<LandingQuickStartTabId, LucideIcon> = {
agent: Bot,
human: UserRound,
cli: Terminal,
}
const ACCESS_MODES: AccessModeOption[] = [
{ id: 'agent', number: '01', title: 'Agent 自动接入', description: '配置一次 Registry按需发现技能' },
{ id: 'cli', number: '02', title: 'CLI 命令行', description: '搜索、获取和发布技能' },
{ id: 'web', number: '03', title: 'Web 界面', description: '可视化浏览、阅读并下载技能' },
]
/**
* Get the base URL for the application.
* Prefers the runtime config if set and not localhost.
* Falls back to the current page origin.
*/
function getAppBaseUrl(): string {
function getRegistryUrl(): string {
if (typeof window === 'undefined') {
return ''
return 'https://skill.xfyun.cn'
}
const runtimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__
return resolvePublicRegistryUrl(
runtimeConfig?.appBaseUrl,
window.__SKILLHUB_RUNTIME_CONFIG__?.appBaseUrl,
`${window.location.protocol}//${window.location.host}`,
)
}
function CompactCopyButton({ text }: { text: string }) {
const { t } = useTranslation()
function AgentAccessPanel() {
const [activeView, setActiveView] = useState<AgentView>('registry')
const [copied, copy] = useCopyToClipboard()
const handleCopy = async () => {
try {
await copy(text)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const label = copied ? (t('copyButton.copied') || 'Copied') : (t('copyButton.copy') || 'Copy')
const registryUrl = useMemo(getRegistryUrl, [])
const instruction = `阅读 ${registryUrl}/registry/skill.md并按照说明完成 SkillHub Skills Registry 的配置`
return (
<button
type="button"
onClick={handleCopy}
aria-label={label}
title={label}
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
<div className="min-h-[340px]">
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="inline-flex w-fit items-center gap-1 border-b border-border/70" role="tablist" aria-label="Agent 接入演示">
{([
['registry', 'Registry 配置'],
['discovery', '隐式发现'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={activeView === id}
onClick={() => setActiveView(id)}
className={`relative px-4 py-2.5 text-xs font-semibold transition-[color,background-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${activeView === id ? 'bg-background/70 text-foreground' : 'text-muted-foreground hover:bg-background/40 hover:text-foreground'}`}
>
{label}
<span className={`absolute inset-x-0 -bottom-px h-0.5 origin-center bg-foreground transition-transform duration-200 ${activeView === id ? 'scale-x-100' : 'scale-x-0'}`} aria-hidden />
</button>
))}
</div>
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
Agent
</span>
</div>
{activeView === 'registry' ? (
<div className="animate-fade-up py-9">
<p className="mb-5 text-xs text-muted-foreground"> Agent </p>
<div className="flex flex-col gap-5 border-y border-border/70 py-5 sm:flex-row sm:items-start">
<span className="mt-1 hidden h-12 w-1 flex-shrink-0 bg-foreground sm:block" aria-hidden />
<p className="min-w-0 flex-1 text-sm leading-7 text-foreground">
<span className="break-all text-blue-600 underline decoration-blue-300 underline-offset-4">{registryUrl}/registry/skill.md</span> SkillHub Skills Registry
</p>
<button
type="button"
onClick={() => {
void copy(instruction).catch(() => {
toast.error('复制失败', '请手动选择并复制这条 Registry 配置指令。')
})
}}
className="inline-flex w-fit flex-shrink-0 items-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-xs font-semibold text-background shadow-sm transition-[transform,opacity,box-shadow] duration-150 hover:-translate-y-px hover:opacity-90 hover:shadow-md active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? '已复制' : '复制指令'}
</button>
</div>
<div className="mt-6 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{['读取文档', '配置 Registry', '按需发现技能'].map((step, index) => (
<div key={step} className="flex items-center gap-2">
{index > 0 ? <span className="h-px w-6 bg-border" /> : null}
<span>{step}</span>
</div>
))}
</div>
</div>
) : (
<div className="animate-fade-up py-8">
<div className="mb-7 flex justify-end">
<span className="border-b border-foreground pb-2 text-sm text-foreground"></span>
</div>
<div className="relative space-y-0 pl-7 before:absolute before:bottom-3 before:left-[5px] before:top-3 before:w-px before:bg-border">
{[
['识别任务意图', '需要实时天气与出行建议'],
['检索 SkillHub Registry', '匹配 @global/weather · v1.3.0'],
['读取技能说明', '确认输入格式和调用方式'],
].map(([title, description], index) => (
<div key={title} className={`relative grid grid-cols-1 gap-1 border-b border-border/60 py-3.5 sm:grid-cols-[9rem_1fr] animate-fade-up delay-${index + 1}`}>
<span className="absolute -left-[26px] top-[20px] h-2.5 w-2.5 rounded-full border-2 border-background bg-muted-foreground ring-1 ring-border" />
<strong className="text-xs font-semibold text-foreground">{title}</strong>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
))}
</div>
<p className="mt-6 border-t-2 border-foreground pt-5 text-sm leading-7 text-foreground">
<strong className="text-emerald-700 dark:text-emerald-400">26°C</strong>
</p>
</div>
)}
</div>
)
}
function CliAccessPanel() {
const [copiedIdx, setCopiedIdx] = useState(-1)
const handleCopy = (text: string, idx: number) => {
void copyToClipboard(text)
.then(() => {
setCopiedIdx(idx)
window.setTimeout(() => setCopiedIdx((prev) => (prev === idx ? -1 : prev)), 2000)
})
.catch(() => {
toast.error('复制失败', '请手动选择并复制这条 CLI 命令。')
})
}
return (
<div className="flex min-h-[380px] flex-col overflow-hidden rounded-2xl border border-border/70 shadow-sm">
{/* Terminal title bar */}
<div className="flex items-center justify-between border-b border-border/60 bg-white px-4 py-3 dark:bg-neutral-900">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<span className="ml-2 font-mono text-xs text-muted-foreground">Terminal skillhub</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-muted-foreground">zsh</span>
<span className="text-[10px] text-border">·</span>
<span className="font-mono text-[10px] text-muted-foreground">80×24</span>
</div>
</div>
{/* Terminal content */}
<div className="flex-1 overflow-auto bg-[#fafafa] p-4 font-mono text-[12px] leading-[1.7] dark:bg-neutral-950">
{/* Version check */}
<div className="mb-3">
<div className="flex items-start gap-2">
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
<div className="flex-1">
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.11 --version</span>
<button
type="button"
onClick={() => handleCopy('npx -y @astron-team/skillhub@0.1.11 --version', 0)}
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
>
{copiedIdx === 0 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copiedIdx === 0 ? '已复制' : '复制'}
</button>
</div>
</div>
<div className="pl-5 text-muted-foreground">SkillHub CLI 0.1.11</div>
</div>
{/* Search */}
<div className="mb-3">
<div className="flex items-start gap-2">
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
<div className="flex-1">
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.11 search weather \</span>
<button
type="button"
onClick={() => handleCopy('npx -y @astron-team/skillhub@0.1.11 search weather --registry https://skill.xfyun.cn --limit 5', 1)}
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
>
{copiedIdx === 1 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copiedIdx === 1 ? '已复制' : '复制'}
</button>
</div>
</div>
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
<span className="text-muted-foreground">{' --registry'} </span>
<span className="text-blue-600 dark:text-blue-400">https://skill.xfyun.cn</span> \
</div>
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
<span className="text-muted-foreground">{' --limit'} </span>
<span className="text-neutral-800 dark:text-neutral-200">5</span>
</div>
<div className="mt-1 space-y-0.5 pl-5">
<div className="text-[11px] text-muted-foreground">Skills found:</div>
<div className="flex gap-4">
<span className="text-blue-600 dark:text-blue-400">@global/weather</span>
<span className="text-muted-foreground">v1.3.0</span>
<span className="text-neutral-600 dark:text-neutral-400"></span>
</div>
<div className="flex gap-4">
<span className="text-blue-600 dark:text-blue-400">@global/forecast</span>
<span className="text-muted-foreground">v2.1.0</span>
<span className="text-neutral-600 dark:text-neutral-400">7 </span>
</div>
</div>
</div>
{/* Install */}
<div className="mb-3">
<div className="flex items-start gap-2">
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
<div className="flex-1">
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.11 install @global/weather \</span>
<button
type="button"
onClick={() => handleCopy('npx -y @astron-team/skillhub@0.1.11 install @global/weather --dir ./skills --registry https://skill.xfyun.cn', 2)}
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
>
{copiedIdx === 2 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copiedIdx === 2 ? '已复制' : '复制'}
</button>
</div>
</div>
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
<span className="text-muted-foreground">{' --dir'} </span>
<span className="text-neutral-800 dark:text-neutral-200">./skills</span> \
</div>
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
<span className="text-muted-foreground">{' --registry'} </span>
<span className="text-blue-600 dark:text-blue-400">https://skill.xfyun.cn</span>
</div>
<div className="mt-1 flex items-center gap-1.5 pl-5 text-[11px] text-emerald-600 dark:text-emerald-400">
<Check className="h-3 w-3" strokeWidth={2.5} />
<span>@global/weather installed to ./skills/global/weather</span>
</div>
</div>
{/* Cursor */}
<div className="flex items-center gap-2">
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
<span className="inline-block h-3.5 w-2 animate-pulse bg-neutral-700 dark:bg-neutral-400" />
</div>
</div>
{/* Bottom status bar */}
<div className="flex items-center justify-between border-t border-border/60 bg-white px-4 py-2.5 text-[11px] dark:bg-neutral-900">
<div className="flex items-center gap-3 text-muted-foreground">
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
connected
</span>
<span className="text-border">·</span>
<span className="font-mono">registry: skill.xfyun.cn</span>
</div>
<a href="https://github.com/iflytek/skillhub/tree/main/cli" target="_blank" rel="noreferrer" className="group flex items-center gap-1 font-medium text-blue-600 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-blue-400 dark:hover:text-blue-300">
CLI
<ChevronRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5 motion-reduce:transform-none" />
</a>
</div>
</div>
)
}
function WebAccessPanel() {
return (
<div className="min-h-[340px]">
<div className="grid gap-6 md:grid-cols-[11rem_minmax(0,1fr)]">
<div className="border-b border-border/70 pb-5 md:border-b-0 md:border-r md:pb-0 md:pr-5">
<div className="mb-4 flex items-center justify-between">
<strong className="text-xs font-semibold text-foreground"></strong>
<span className="text-[10px] text-muted-foreground"></span>
</div>
<div className="mb-3 flex items-center gap-2 border-b border-border/70 pb-2.5 text-[11px] text-muted-foreground">
<Search className="h-3.5 w-3.5" />
</div>
{[
['W', 'weather', '@global'],
['G', 'git-helper', '@devtools'],
['D', 'diagram-maker', '@global'],
].map(([letter, name, namespace], index) => (
<div key={name} className={`flex items-center gap-2.5 border-b border-border/50 py-3 ${index === 0 ? 'text-foreground' : 'text-muted-foreground'}`}>
<span className={`flex h-7 w-7 items-center justify-center rounded-md text-[10px] font-bold ${index === 0 ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300' : 'bg-secondary'}`}>{letter}</span>
<span className="min-w-0">
<strong className="block truncate text-[11px] font-semibold">{name}</strong>
<span className="font-mono text-[9px]">{namespace}</span>
</span>
</div>
))}
</div>
<div className="py-1">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> ·
</span>
<h3 className="mt-2 text-xl font-semibold tracking-tight text-foreground">weather <span className="font-mono text-xs font-normal text-muted-foreground">@global</span></h3>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
<a href={buildApiUrl(`${WEB_API_PREFIX}/skills/global/weather/download`)} className="inline-flex w-fit items-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-xs font-semibold text-background shadow-sm transition-[transform,opacity,box-shadow] duration-150 hover:-translate-y-px hover:opacity-90 hover:shadow-md active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none">
<Download className="h-3.5 w-3.5" /> ZIP
</a>
</div>
<div className="mt-6 grid gap-5 border-t border-border/70 pt-5 sm:grid-cols-[minmax(0,1fr)_8rem]">
<div>
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-foreground">
<FileText className="h-3.5 w-3.5" />
使
</div>
<p className="text-[11px] leading-6 text-muted-foreground">
SKILL.md
</p>
</div>
<dl className="space-y-3 text-[10px]">
<div className="flex justify-between border-b border-border/60 pb-2"><dt className="text-muted-foreground"></dt><dd className="font-mono text-foreground">v1.3.0</dd></div>
<div className="flex justify-between border-b border-border/60 pb-2"><dt className="text-muted-foreground"></dt><dd className="text-foreground">3</dd></div>
<div className="flex justify-between"><dt className="text-muted-foreground"></dt><dd className="text-foreground">ZIP</dd></div>
</dl>
</div>
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[11px] text-muted-foreground">
<span></span><span></span><span></span><span></span>
</div>
</div>
</div>
</div>
)
}
export function LandingQuickStartSection() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<LandingQuickStartTabId>('agent')
const baseUrl = useMemo(() => getAppBaseUrl(), [])
// Build dynamic agent command with actual registry URL
const agentCommand = t('landing.quickStart.agent.commandTemplate', {
defaultValue: t('landing.quickStart.agent.command'),
url: `${baseUrl}/install/skillhub.md`,
})
const humanCommand = t('landing.quickStart.human.commandTemplate', {
defaultValue: t('landing.quickStart.human.command'),
url: baseUrl,
})
const tabs: LandingQuickStartTab[] = [
{
id: 'agent',
label: t('landing.quickStart.tabs.agent'),
description: t('landing.quickStart.agent.description'),
command: agentCommand,
},
{
id: 'human',
label: t('landing.quickStart.tabs.human'),
description: t('landing.quickStart.human.description'),
command: humanCommand,
},
{
id: 'cli',
label: t('landing.quickStart.tabs.cli'),
description: t('landing.quickStart.cli.description'),
command: t('landing.quickStart.cli.command'),
},
]
const currentTab = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]
const [activeMode, setActiveMode] = useState<AccessMode>('agent')
return (
<section className="relative z-10 w-full px-6 py-14 md:py-16" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-7 md:mb-8">
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-3" style={{ color: 'hsl(var(--foreground))' }}>
{t('landing.quickStart.title')}
</h2>
<p className="text-base md:text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
{t('landing.quickStart.description', { defaultValue: t('landing.quickStart.subtitle') })}
</p>
<section id="quickstart" className="relative z-10 w-full overflow-hidden bg-secondary/70 px-6 py-16 md:py-20">
<div className="absolute inset-0 bg-dots opacity-40" aria-hidden />
<div className="relative mx-auto max-w-6xl">
<div className="mb-12 max-w-3xl">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Quick Start</p>
<h2 className="mb-4 text-3xl font-medium tracking-tight text-foreground md:text-4xl"></h2>
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground"></p>
</div>
<div
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
>
{tabs.map((tab) => {
const isActive = tab.id === currentTab.id
const Icon = tabIcons[tab.id]
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-12">
<div className="border-t border-border/70">
{ACCESS_MODES.map((mode) => {
const active = activeMode === mode.id
return (
<button
key={tab.id}
key={mode.id}
type="button"
onClick={() => setActiveTab(tab.id)}
aria-pressed={isActive}
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
style={{
background: isActive ? 'hsl(var(--card))' : 'transparent',
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
}}
aria-pressed={active}
onClick={() => setActiveMode(mode.id)}
className={`group relative flex w-full items-center gap-4 border-b border-border/70 px-2 py-5 text-left transition-[background-color,transform] duration-200 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset ${active ? 'bg-background/65' : 'hover:bg-background/35 active:translate-x-px'}`}
>
<Icon className="h-4 w-4" strokeWidth={1.75} />
<span>{tab.label}</span>
<span className={`absolute inset-y-3 left-0 w-0.5 origin-center bg-foreground transition-transform duration-200 ${active ? 'scale-y-100' : 'scale-y-0'}`} aria-hidden />
<span className={`font-mono text-[11px] font-semibold transition-colors duration-200 ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{mode.number}</span>
<span className="min-w-0 flex-1">
<strong className={`block text-sm font-semibold transition-colors ${active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground'}`}>{mode.title}</strong>
<span className="mt-1 block text-[11px] text-muted-foreground">{mode.description}</span>
</span>
<span className={`h-1.5 w-1.5 rounded-full transition-colors ${active ? 'bg-foreground' : 'bg-border'}`} />
</button>
)
})}
</div>
<div className="px-4 pb-4 pt-8 md:px-8 md:pb-6 md:pt-9">
<p
className="mx-auto mb-6 max-w-xl text-center text-base font-medium leading-relaxed md:text-lg"
style={{ color: 'hsl(var(--foreground))' }}
>
{currentTab.description}
</p>
<div
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
style={{ borderColor: 'hsl(var(--border))' }}
>
<div className="overflow-x-auto whitespace-nowrap">
<code
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
>
{currentTab.command}
</code>
</div>
<CompactCopyButton text={currentTab.command} />
</div>
<div key={activeMode} className="animate-fade-up border-t border-border/70 pt-5">
{activeMode === 'agent' ? <AgentAccessPanel /> : null}
{activeMode === 'cli' ? <CliAccessPanel /> : null}
{activeMode === 'web' ? <WebAccessPanel /> : null}
</div>
</div>
</div>

View file

@ -61,18 +61,18 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
size="sm"
aria-expanded={open}
aria-haspopup="menu"
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
className={cn('cursor-pointer gap-1.5 text-muted-foreground hover:text-foreground transition-colors', className)}
onClick={() => setOpen((current) => !current)}
>
<Globe className="h-4 w-4" />
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
<span className="hidden text-sm font-medium text-inherit sm:inline">{currentLanguage.name}</span>
<ChevronDown className={cn('hidden h-3.5 w-3.5 transition-transform duration-200 sm:block', open && 'rotate-180')} />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 pt-2">
<div className="absolute right-0 top-full z-50 pt-1.5">
<div
role="menu"
className="flex min-w-[9rem] flex-col gap-1.5 rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
className="flex min-w-[8rem] flex-col overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
{languages.map((lang) => (
<button
@ -81,8 +81,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
role="menuitem"
onClick={() => changeLanguage(lang.code)}
className={cn(
'cursor-pointer rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
currentLangCode === lang.code ? 'bg-accent' : ''
'w-full cursor-pointer rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
currentLangCode === lang.code && 'bg-accent text-accent-foreground'
)}
>
{lang.name}

View file

@ -10,10 +10,10 @@ export function NamespaceBadge({ type, name, className }: NamespaceBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium border transition-colors',
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border transition-colors',
type === 'GLOBAL'
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/15'
: 'bg-accent/10 text-accent border-accent/20 hover:bg-accent/15',
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
className
)}
>

View file

@ -23,29 +23,29 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
title={label}
onClick={toggleTheme}
className={cn(
'group relative inline-flex h-11 w-16 shrink-0 items-center rounded-full border border-border bg-muted/70 px-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'group relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-border/50 bg-muted/60 px-0.5 text-muted-foreground transition-[background-color,border-color] duration-150 hover:border-border focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15',
className,
)}
>
<span
aria-hidden="true"
className={cn(
'absolute left-1 top-1.5 h-8 w-7 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
isDark && 'translate-x-7',
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full border border-border/50 bg-card shadow-[0_1px_2px_0_rgb(0_0_0/0.12)] transition-[transform,box-shadow] duration-150 ease-out motion-reduce:transition-none',
isDark && 'translate-x-[20px]',
)}
/>
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
<span className="relative z-10 inline-flex h-5 w-5 items-center justify-center">
<Sun
aria-hidden="true"
className={cn('h-4 w-4 transition-colors duration-200', !isDark && 'text-foreground')}
className={cn('h-3 w-3 transition-colors duration-150', !isDark && 'text-foreground')}
/>
</span>
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
<span className="relative z-10 inline-flex h-5 w-5 items-center justify-center">
<Moon
aria-hidden="true"
className={cn('h-4 w-4 transition-colors duration-200', isDark && 'text-foreground')}
className={cn('h-3 w-3 transition-colors duration-150', isDark && 'text-foreground')}
/>
</span>
</button>
)
}
}

View file

@ -76,8 +76,8 @@ describe('user-menu module exports', () => {
})
})
describe('UserMenu security settings visibility', () => {
it('keeps author review progress separate from reviewer management', () => {
describe('UserMenu navigation', () => {
it('keeps dashboard-only personal links out of the compact avatar menu', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
@ -87,36 +87,37 @@ describe('UserMenu security settings visibility', () => {
/>,
)
expect(html).toContain('user.menu.reviewProgress')
expect(html).not.toContain('user.menu.reviews')
expect(html).toContain('user.menu.dashboard')
expect(html).not.toContain('user.menu.reviewProgress')
expect(html).not.toContain('user.menu.security')
})
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
it('keeps administrator links available to administrators', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'OAuth Linked User',
oauthProvider: 'github',
platformRoles: ['USER'],
canChangePassword: true,
displayName: 'Administrator',
platformRoles: ['SUPER_ADMIN'],
}}
/>,
)
expect(html).toContain('user.menu.security')
expect(html).toContain('user.menu.users')
expect(html).toContain('user.menu.labels')
expect(html).toContain('user.menu.namespacesAdmin')
expect(html).toContain('user.menu.auditLog')
})
it('hides security settings when password changes are not allowed, even for a local-looking account', () => {
it('always keeps logout available', () => {
const html = renderToStaticMarkup(
<UserMenu
user={{
displayName: 'Local User',
platformRoles: ['USER'],
canChangePassword: false,
}}
/>,
)
expect(html).not.toContain('user.menu.security')
expect(html).toContain('user.menu.logout')
})
})

View file

@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths'
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
import { withBasePath } from '@/shared/lib/base-path'
import { cn } from '@/shared/lib/utils'
@ -26,20 +23,15 @@ interface UserMenuProps {
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: myNamespaces } = useMyNamespaces()
const rootRef = useRef<HTMLDivElement | null>(null)
const closeTimerRef = useRef<number | null>(null)
const [isHovered, setIsHovered] = useState(false)
const [isClickOpen, setIsClickOpen] = useState(false)
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
const canSeeGovernance = canViewGovernanceCenter(user.platformRoles)
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
const isSuperAdmin = hasRole('SUPER_ADMIN')
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
const canChangePassword = user.canChangePassword === true
const open = isHovered || isClickOpen
const clearCloseTimer = () => {
@ -122,15 +114,19 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-md', triggerClassName)}
onClick={() => setIsClickOpen((current) => !current)}
>
{user.avatarUrl && (
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.displayName}
loading="lazy"
className="w-8 h-8 rounded-full border border-border/60"
/>
) : (
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent text-sm font-semibold text-accent-foreground flex-shrink-0">
{user.displayName?.charAt(0) ?? '?'}
</span>
)}
<span className="hidden text-sm font-medium text-inherit lg:inline">
<span className="sr-only">
{user.displayName}
</span>
</button>
@ -147,41 +143,6 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
<Link to="/dashboard" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.dashboard')}
</Link>
<Link to="/dashboard/skills" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.mySkills')}
</Link>
<Link to="/dashboard/namespaces" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.myNamespaces')}
</Link>
{canSeeGovernance ? (
<Link to="/dashboard/governance" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.governance')}
</Link>
) : null}
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.stars')}
</Link>
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.subscriptions')}
</Link>
<Link to="/dashboard/review-progress" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviewProgress')}
</Link>
{reviewCenterVisible ? (
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reviews')}
</Link>
) : null}
{isSkillAdmin ? (
<Link to="/dashboard/promotions" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.promotions')}
</Link>
) : null}
{isSkillAdmin ? (
<Link to="/dashboard/reports" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.reports')}
</Link>
) : null}
{isUserAdmin || isAuditor || isSuperAdmin ? <div className="-mx-1 my-1 h-px bg-muted" /> : null}
{isUserAdmin ? (
<Link to="/admin/users" className={menuItemClassName} onClick={closeMenu}>
@ -204,18 +165,6 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
</Link>
) : null}
<div className="-mx-1 my-1 h-px bg-muted" />
<Link to="/settings/profile" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.profile')}
</Link>
<Link to="/settings/notifications" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.notifications')}
</Link>
{canChangePassword ? (
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.security')}
</Link>
) : null}
<div className="-mx-1 my-1 h-px bg-muted" />
<button
type="button"
onClick={handleLogout}

View file

@ -8,6 +8,25 @@ async function getMyNamespaces(): Promise<ManagedNamespace[]> {
return namespaceApi.listMine()
}
async function getMyNamespacesPage(params: { page?: number; size?: number } = {}): Promise<PagedResponse<ManagedNamespace>> {
try {
return await namespaceApi.listMinePage(params)
} catch {
// Local development may still be backed by an older server image without the paginated endpoint.
// Fall back to the legacy list endpoint and slice client-side so the page remains usable.
const namespaces = await namespaceApi.listMine()
const page = params.page ?? 0
const size = params.size ?? 10
const start = page * size
return {
items: namespaces.slice(start, start + size),
total: namespaces.length,
page,
size,
}
}
}
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {
return namespaceApi.create(request)
}
@ -58,6 +77,14 @@ export function useMyNamespaces() {
})
}
export function useMyNamespacesPage(params: { page?: number; size?: number } = {}) {
return useQuery({
queryKey: ['namespaces', 'my', 'page', params],
queryFn: () => getMyNamespacesPage(params),
placeholderData: (previousData) => previousData,
})
}
export function useCreateNamespace() {
const queryClient = useQueryClient()

View file

@ -4,9 +4,9 @@ import { buttonVariants } from './button'
describe('buttonVariants', () => {
it('applies default variant and size classes', () => {
const classes = buttonVariants()
expect(classes).toContain('bg-brand-gradient')
expect(classes).toContain('h-10')
expect(classes).toContain('px-5')
expect(classes).toContain('bg-primary')
expect(classes).toContain('h-9')
expect(classes).toContain('px-4')
})
it('applies destructive variant classes', () => {
@ -17,7 +17,7 @@ describe('buttonVariants', () => {
it('applies outline variant classes', () => {
const classes = buttonVariants({ variant: 'outline' })
expect(classes).toContain('border')
expect(classes).toContain('bg-transparent')
expect(classes).toContain('bg-background/70')
})
it('applies secondary variant classes', () => {
@ -27,7 +27,7 @@ describe('buttonVariants', () => {
it('applies ghost variant classes', () => {
const classes = buttonVariants({ variant: 'ghost' })
expect(classes).toContain('hover:bg-secondary')
expect(classes).toContain('hover:bg-accent/70')
})
it('applies link variant classes', () => {
@ -43,14 +43,14 @@ describe('buttonVariants', () => {
it('applies lg size classes', () => {
const classes = buttonVariants({ size: 'lg' })
expect(classes).toContain('h-12')
expect(classes).toContain('text-base')
expect(classes).toContain('h-10')
expect(classes).toContain('px-8')
})
it('applies icon size classes', () => {
const classes = buttonVariants({ size: 'icon' })
expect(classes).toContain('h-10')
expect(classes).toContain('w-10')
expect(classes).toContain('h-9')
expect(classes).toContain('w-9')
})
it('always includes base focus-visible and disabled styles', () => {

View file

@ -3,28 +3,30 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/shared/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
// 基础:紧致圆角、精准属性过渡、按下回弹、聚焦双层光晕
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,box-shadow,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] select-none',
{
variants: {
variant: {
// default三层阴影inset 顶高光 + 底部投影 + 1px 描边),模拟立体片状按钮
default:
'bg-brand-gradient text-white shadow-sm hover:opacity-95 active:scale-[0.98]',
'bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.10),0_1px_2px_0_rgb(0_0_0/0.10),0_0_0_1px_rgb(0_0_0/0.04)] hover:bg-primary/92 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.14),0_2px_4px_-1px_rgb(0_0_0/0.14),0_0_0_1px_rgb(0_0_0/0.06)] active:shadow-[inset_0_1px_2px_0_rgb(0_0_0/0.14)]',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
'bg-destructive text-destructive-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.12),0_1px_2px_0_rgb(0_0_0/0.10)] hover:bg-destructive/92 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.16),0_2px_4px_-1px_rgb(0_0_0/0.14)]',
outline:
'border border-border bg-transparent hover:bg-secondary hover:border-primary/30 hover:text-foreground active:scale-[0.98]',
'border border-border/60 bg-background/70 text-foreground shadow-[0_1px_1px_0_rgb(0_0_0/0.04)] hover:bg-accent/80 hover:border-border hover:text-accent-foreground hover:shadow-[0_1px_2px_0_rgb(0_0_0/0.06)]',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
'bg-secondary text-secondary-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.04),0_1px_1px_0_rgb(0_0_0/0.04)] hover:bg-secondary/80 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.06),0_1px_2px_0_rgb(0_0_0/0.06)]',
ghost:
'hover:bg-secondary hover:text-foreground',
'hover:bg-accent/70 hover:text-accent-foreground',
link:
'text-primary underline-offset-4 hover:underline',
'text-primary underline-offset-4 hover:underline active:scale-100',
},
size: {
default: 'h-10 px-5 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-10 w-10',
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-sm px-3 text-xs',
lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
@ -39,10 +41,11 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
({ className, variant, size, style, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
style={style}
ref={ref}
{...props}
/>

View file

@ -2,14 +2,14 @@ import * as React from 'react'
import { cn } from '@/shared/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
({ className, style, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md',
'rounded-md border border-border/60 bg-card text-card-foreground transition-[border-color,box-shadow,transform] duration-150 ease-out hover:border-[hsl(214_50%_88%)] hover:shadow-[var(--shadow-card-hover)]',
className
)}
style={{ borderColor: 'hsl(var(--border-card))' }}
style={{ boxShadow: 'var(--shadow-card)', ...style }}
{...props}
/>
)
@ -18,14 +18,14 @@ Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-semibold font-heading leading-none tracking-tight', className)} {...props} />
<h3 ref={ref} className={cn('text-lg font-semibold font-heading leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
@ -38,13 +38,13 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
({ className, ...props }, ref) => <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'

View file

@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
translate="no"
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
className={cn('fixed inset-0 z-50 bg-black/40 backdrop-blur-sm', className)}
{...props}
/>
))
@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
ref={ref}
translate="no"
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),30rem)] -translate-x-1/2 -translate-y-1/2 gap-5 overflow-y-auto rounded-2xl border border-border/40 bg-card text-card-foreground p-6 shadow-[0_20px_60px_-12px_rgb(0_0_0/0.30),0_0_0_1px_rgb(0_0_0/0.04)]',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -66,7 +66,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = 'DialogContent'
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center', className)} {...props} />
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
@ -81,7 +81,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)}
className={cn('text-lg font-semibold leading-tight tracking-tight', className)}
{...props}
/>
))
@ -93,7 +93,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-center text-sm text-muted-foreground', className)}
className={cn('text-sm text-muted-foreground/80', className)}
{...props}
/>
))

View file

@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const INPUT_BASE_CLASS_NAME =
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
'flex h-9 w-full rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/70 hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-background focus-visible:ring-4 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:opacity-50'
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, style, ...props }, ref) => {
@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
INPUT_BASE_CLASS_NAME,
className
)}
style={{ borderColor: 'hsl(var(--border))', ...style }}
style={{ ...style }}
ref={ref}
{...props}
/>

View file

@ -10,14 +10,14 @@ import {
describe('shared select contract', () => {
it('keeps the trigger aligned with the existing input styling language', () => {
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-11')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('rounded-lg')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-9')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('rounded-md')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('border-border/60')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('bg-secondary/50')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('bg-background/40')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:outline-none')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-2')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-primary/40')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:border-primary/50')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-4')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-ring/15')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:border-ring')
})
it('uses themed panel and item classes for the floating listbox', () => {

View file

@ -1,18 +1,19 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { getPortalContainer } from '@/shared/lib/portal-container'
import { cn } from '@/shared/lib/utils'
export const SELECT_TRIGGER_CLASS_NAME = cn(
'flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground',
'ring-offset-background transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50',
'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground',
'ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out',
'hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-card focus-visible:ring-4 focus-visible:ring-ring/15',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1'
)
export const SELECT_CONTENT_CLASS_NAME = cn(
'z-50 max-h-[var(--radix-select-content-available-height)] overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
'z-50 max-h-[var(--radix-select-content-available-height)] w-fit min-w-48 max-w-[18rem] overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
// In-tree (no Portal): avoids React 19 removeChild races on route unmount.
// No exit animations: delayed unmount still races commits when Content was portaled.
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
@ -89,33 +90,43 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', sideOffset = 4, ...props }, ref) => (
// No Portal: Content stays in the React tree with its trigger so route/Dialog
// unmount cannot orphan a body/#skillhub-portals node (removeChild).
<SelectPrimitive.Content
ref={ref}
translate="no"
sideOffset={sideOffset}
className={cn(
SELECT_CONTENT_CLASS_NAME,
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
>(({ className, children, position = 'popper', sideOffset = 4, ...props }, ref) => {
const portalContainer = getPortalContainer()
const content = (
<SelectPrimitive.Content
ref={ref}
translate="no"
sideOffset={sideOffset}
className={cn(
'p-1',
position === 'popper'
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
SELECT_CONTENT_CLASS_NAME,
className
)}
position={position}
{...props}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
))
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
)
// Portal to the shared container when available (e.g. inside a Dialog) so
// the dropdown is not clipped by overflow:hidden / overflow-y-auto ancestors.
// In-tree still works fine for non-modal contexts.
if (portalContainer) {
return React.createElement(SelectPrimitive.Portal, { container: portalContainer }, content)
}
return content
})
SelectContent.displayName = SelectPrimitive.Content.displayName

View file

@ -27,8 +27,8 @@ const config: Config = {
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
md: 'var(--radius)',
sm: 'calc(var(--radius) - 2px)',
xl: 'calc(var(--radius) + 4px)',
'2xl': 'calc(var(--radius) + 8px)',
},