mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(web): implement login page, dashboard, and API token management
- Login page with dynamic OAuth provider buttons from /api/v1/auth/providers - Dashboard with AuthGuard, user info display, and token management - TokenList component with create/delete operations via TanStack Query - CreateTokenDialog with one-time token display and copy functionality - API client with typed auth and token endpoints - shadcn/ui components: Button, Card, Dialog, Table, Input, Label - Tailwind CSS with CSS variables theming - TanStack Router with Home, Login, Dashboard routes - ESLint + TypeScript strict mode configuration
This commit is contained in:
parent
ff5ecc90ac
commit
e6956e79fc
31 changed files with 4003 additions and 0 deletions
17
web/.eslintrc.cjs
Normal file
17
web/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
16
web/components.json
Normal file
16
web/components.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/ui",
|
||||
"utils": "@/shared/lib/utils"
|
||||
}
|
||||
}
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SkillHub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
web/package.json
Normal file
38
web/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "skillhub-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@tanstack/react-query": "^5.64.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5"
|
||||
}
|
||||
}
|
||||
2721
web/pnpm-lock.yaml
generated
Normal file
2721
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
78
web/src/api/client.ts
Normal file
78
web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type {
|
||||
User,
|
||||
OAuthProvider,
|
||||
ApiToken,
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
ApiResponse,
|
||||
} from './types'
|
||||
|
||||
// 基础 fetch 封装
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: 'Request failed' }))
|
||||
throw new Error(error.message || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
// 获取当前用户信息
|
||||
async getMe(): Promise<User | null> {
|
||||
try {
|
||||
return await fetchJson<User>('/api/v1/auth/me')
|
||||
} catch (error) {
|
||||
// 401 表示未登录,返回 null
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 获取可用的 OAuth 提供商
|
||||
async getProviders(): Promise<OAuthProvider[]> {
|
||||
const response = await fetchJson<ApiResponse<OAuthProvider[]>>('/api/v1/auth/providers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout(): Promise<void> {
|
||||
await fetch('/api/v1/auth/logout', { method: 'POST' })
|
||||
},
|
||||
}
|
||||
|
||||
// Token API
|
||||
export const tokenApi = {
|
||||
// 获取所有 Token
|
||||
async getTokens(): Promise<ApiToken[]> {
|
||||
const response = await fetchJson<ApiResponse<ApiToken[]>>('/api/v1/tokens')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 创建新 Token
|
||||
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
|
||||
const response = await fetchJson<ApiResponse<CreateTokenResponse>>('/api/v1/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 删除 Token
|
||||
async deleteToken(tokenId: number): Promise<void> {
|
||||
await fetch(`/api/v1/tokens/${tokenId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
52
web/src/api/types.ts
Normal file
52
web/src/api/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// API 类型定义
|
||||
|
||||
export interface User {
|
||||
userId: number
|
||||
displayName: string
|
||||
email: string
|
||||
avatarUrl: string
|
||||
oauthProvider: string
|
||||
platformRoles: string[]
|
||||
}
|
||||
|
||||
export interface OAuthProvider {
|
||||
id: string
|
||||
name: string
|
||||
authorizationUrl: string
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
tokenId: number
|
||||
name: string
|
||||
tokenPrefix: string
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateTokenRequest {
|
||||
name: string
|
||||
expiresInDays?: number
|
||||
}
|
||||
|
||||
export interface CreateTokenResponse {
|
||||
token: string
|
||||
tokenId: number
|
||||
name: string
|
||||
tokenPrefix: string
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
message: string
|
||||
path: string
|
||||
timestamp: string
|
||||
requestId: string
|
||||
}
|
||||
45
web/src/app/layout.tsx
Normal file
45
web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Outlet, Link } from '@tanstack/react-router'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
|
||||
export function Layout() {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-14 items-center justify-between px-4">
|
||||
<Link to="/" className="text-lg font-semibold hover:text-primary">
|
||||
SkillHub
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
{isLoading ? null : user ? (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-sm hover:text-primary"
|
||||
activeProps={{ className: 'text-primary font-medium' }}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm hover:text-primary"
|
||||
activeProps={{ className: 'text-primary font-medium' }}
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
web/src/app/providers.tsx
Normal file
21
web/src/app/providers.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 分钟
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
37
web/src/app/router.tsx
Normal file
37
web/src/app/router.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router'
|
||||
import { Layout } from './layout'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { LoginPage } from '@/pages/login'
|
||||
import { DashboardPage } from '@/pages/dashboard'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: Layout,
|
||||
})
|
||||
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const dashboardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard',
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([homeRoute, loginRoute, dashboardRoute])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
32
web/src/features/auth/auth-guard.tsx
Normal file
32
web/src/features/auth/auth-guard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuth } from './use-auth'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
}, [isLoading, isAuthenticated, navigate])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
39
web/src/features/auth/login-button.tsx
Normal file
39
web/src/features/auth/login-button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import type { OAuthProvider } from '@/api/types'
|
||||
|
||||
export function LoginButton() {
|
||||
const { data, isLoading } = useQuery<OAuthProvider[]>({
|
||||
queryKey: ['auth', 'providers'],
|
||||
queryFn: authApi.getProviders,
|
||||
})
|
||||
|
||||
const providers = data ?? []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" disabled>
|
||||
加载中...
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = provider.authorizationUrl
|
||||
}}
|
||||
>
|
||||
使用 {provider.name} 登录
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
web/src/features/auth/use-auth.ts
Normal file
20
web/src/features/auth/use-auth.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import type { User } from '@/api/types'
|
||||
|
||||
export function useAuth() {
|
||||
const { data: user, isLoading, error } = useQuery<User | null>({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: authApi.getMe,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 分钟
|
||||
})
|
||||
|
||||
return {
|
||||
user: user ?? null,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
hasRole: (role: string) => user?.platformRoles?.includes(role) ?? false,
|
||||
error,
|
||||
}
|
||||
}
|
||||
126
web/src/features/token/create-token-dialog.tsx
Normal file
126
web/src/features/token/create-token-dialog.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { tokenApi } from '@/api/client'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types'
|
||||
|
||||
interface CreateTokenDialogProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request),
|
||||
onSuccess: (data) => {
|
||||
setCreatedToken(data)
|
||||
setName('')
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) return
|
||||
createMutation.mutate({ name: name.trim() })
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setCreatedToken(null)
|
||||
setName('')
|
||||
createMutation.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
{!createdToken ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 API Token</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建一个新的 API Token 用于 CLI 或 API 访问
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name">Token 名称</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="例如: my-cli-token"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token 创建成功</DialogTitle>
|
||||
<DialogDescription>
|
||||
请立即复制并保存此 Token,它只会显示一次
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Token</Label>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-sm break-all">
|
||||
{createdToken.token}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<div className="text-sm">{createdToken.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdToken.token)
|
||||
}}
|
||||
>
|
||||
复制 Token
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
102
web/src/features/token/token-list.tsx
Normal file
102
web/src/features/token/token-list.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { tokenApi } from '@/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import { CreateTokenDialog } from './create-token-dialog'
|
||||
import type { ApiToken } from '@/api/types'
|
||||
|
||||
export function TokenList() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: tokens, isLoading } = useQuery<ApiToken[]>({
|
||||
queryKey: ['tokens'],
|
||||
queryFn: tokenApi.getTokens,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (tokenId: number, name: string) => {
|
||||
if (window.confirm(`确定要删除 Token "${name}" 吗?`)) {
|
||||
deleteMutation.mutate(tokenId)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Tokens</h2>
|
||||
<CreateTokenDialog>
|
||||
<Button>创建新 Token</Button>
|
||||
</CreateTokenDialog>
|
||||
</div>
|
||||
|
||||
{!tokens || tokens.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>还没有创建任何 Token</p>
|
||||
<p className="text-sm mt-2">点击上方按钮创建第一个 Token</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Token 前缀</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>过期时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.tokenId}>
|
||||
<TableCell className="font-medium">{token.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(token.createdAt)}</TableCell>
|
||||
<TableCell>{formatDate(token.lastUsedAt)}</TableCell>
|
||||
<TableCell>{formatDate(token.expiresAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(token.tokenId, token.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
web/src/index.css
Normal file
59
web/src/index.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './app/providers'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
63
web/src/pages/dashboard.tsx
Normal file
63
web/src/pages/dashboard.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { AuthGuard } from '@/features/auth/auth-guard'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { TokenList } from '@/features/token/token-list'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
管理你的账户和 API Tokens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户信息</CardTitle>
|
||||
<CardDescription>你的账户详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{user?.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="h-16 w-16 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold">{user?.displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
通过 {user?.oauthProvider} 登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.platformRoles && user.platformRoles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">平台角色</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.platformRoles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TokenList />
|
||||
</div>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
18
web/src/pages/home.tsx
Normal file
18
web/src/pages/home.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function HomePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">SkillHub</h1>
|
||||
<p className="text-xl text-muted-foreground">技能注册中心</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
欢迎来到 SkillHub,这是一个技能管理和分享平台。
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
请登录以访问完整功能。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
web/src/pages/login.tsx
Normal file
17
web/src/pages/login.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { LoginButton } from '@/features/auth/login-button'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">登录 SkillHub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
选择一个方式登录以继续
|
||||
</p>
|
||||
</div>
|
||||
<LoginButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
web/src/shared/lib/utils.ts
Normal file
6
web/src/shared/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
48
web/src/shared/ui/button.tsx
Normal file
48
web/src/shared/ui/button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from 'react'
|
||||
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-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
48
web/src/shared/ui/card.tsx
Normal file
48
web/src/shared/ui/card.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
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} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
)
|
||||
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} />
|
||||
)
|
||||
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} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
167
web/src/shared/ui/dialog.tsx
Normal file
167
web/src/shared/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface DialogContextValue {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined)
|
||||
|
||||
function useDialog() {
|
||||
const context = React.useContext(DialogContext)
|
||||
if (!context) {
|
||||
throw new Error('Dialog components must be used within Dialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const open = controlledOpen ?? uncontrolledOpen
|
||||
const handleOpenChange = onOpenChange ?? setUncontrolledOpen
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ onClick, asChild, children, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onOpenChange(true)
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<{ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void }>, {
|
||||
onClick: handleClick,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button ref={ref} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
DialogTrigger.displayName = 'DialogTrigger'
|
||||
|
||||
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDialog()
|
||||
if (!open) return null
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
DialogOverlay.displayName = 'DialogOverlay'
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
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"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
)
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
||||
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
)
|
||||
DialogDescription.displayName = 'DialogDescription'
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
23
web/src/shared/ui/input.tsx
Normal file
23
web/src/shared/ui/input.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
20
web/src/shared/ui/label.tsx
Normal file
20
web/src/shared/ui/label.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
57
web/src/shared/ui/table.tsx
Normal file
57
web/src/shared/ui/table.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as React from 'react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
52
web/tailwind.config.ts
Normal file
52
web/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
24
web/tsconfig.json
Normal file
24
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
web/vite.config.ts
Normal file
29
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth2': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/login': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue