mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
cap 与系统信息
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {CheckCircle2, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion} from 'lucide-react'
|
||||
import {getCapToken} from '@/lib/cap-solver'
|
||||
|
||||
type CapStatus = 'idle' | 'solving' | 'solved' | 'error'
|
||||
|
||||
interface CapWidgetProps {
|
||||
/** Called with the one-time token once the challenge is solved */
|
||||
onToken: (token: string) => void
|
||||
/** Called when the challenge fails or encounters an error */
|
||||
onError?: (err: Error) => void
|
||||
/** Whether to auto-start solving when the component mounts. Defaults to true. */
|
||||
autoStart?: boolean
|
||||
/** PoW scope sent to the backend */
|
||||
scope?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap 人机验证小部件
|
||||
*
|
||||
* autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。
|
||||
* autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。
|
||||
*/
|
||||
export function CapWidget({ onToken, onError, autoStart = true, scope = 'login' }: CapWidgetProps) {
|
||||
const [status, setStatus] = useState<CapStatus>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const solving = useRef(false)
|
||||
|
||||
const solve = async () => {
|
||||
if (solving.current) return
|
||||
solving.current = true
|
||||
setStatus('solving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const token = await getCapToken(scope)
|
||||
setStatus('solved')
|
||||
onToken(token)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setStatus('error')
|
||||
setErrorMsg(error.message)
|
||||
onError?.(error)
|
||||
} finally {
|
||||
solving.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start on mount (only when autoStart is enabled)
|
||||
useEffect(() => {
|
||||
if (autoStart) {
|
||||
void solve()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
{/* idle + autoStart=false: 手动触发按钮 */}
|
||||
{status === 'idle' && !autoStart && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => void solve()}
|
||||
>
|
||||
<ShieldQuestion className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground">点击开始人机验证</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* idle + autoStart=true: 等待自动开始(极短暂状态) */}
|
||||
{status === 'idle' && autoStart && (
|
||||
<>
|
||||
<ShieldAlert className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">等待人机验证…</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'solving' && (
|
||||
<>
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">正在完成人机验证…</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'solved' && (
|
||||
<>
|
||||
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">人机验证通过</span>
|
||||
<ShieldCheck className="ml-auto size-4 shrink-0 text-green-500" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => void solve()}
|
||||
>
|
||||
<ShieldAlert className="size-4 shrink-0 text-destructive" />
|
||||
<span className="flex-1 text-destructive">
|
||||
{errorMsg || '人机验证失败'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground underline">重试</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useMemo, useRef, useState} from "react"
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {useRouter, useSearchParams} from "next/navigation"
|
||||
import {KeyRound, ShieldCheck, UserPlus} from "lucide-react"
|
||||
@@ -13,7 +13,9 @@ import {Separator} from "@/components/ui/separator"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {Card, CardContent} from "@/components/ui/card"
|
||||
import {CapWidget} from "@/components/auth/cap-widget"
|
||||
import services from "@/lib/services"
|
||||
import type {LoginRequest, RegisterRequest} from "@/lib/services/auth/types"
|
||||
|
||||
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
const callbackUrl = searchParams.get("callbackUrl")
|
||||
@@ -44,6 +46,12 @@ export function LoginForm() {
|
||||
const [nickname, setNickname] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// Cap token management — ref to hold latest token without triggering re-render
|
||||
const capTokenRef = useRef<string | null>(null)
|
||||
const [capReady, setCapReady] = useState(false)
|
||||
const [capError, setCapError] = useState(false)
|
||||
const [capResetKey, setCapResetKey] = useState(0)
|
||||
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => services.config.getPublicConfig(),
|
||||
@@ -60,19 +68,36 @@ export function LoginForm() {
|
||||
[searchParams],
|
||||
)
|
||||
|
||||
const capEnabled = publicConfigQuery.data?.cap_login_enabled ?? false
|
||||
const capAutoSolve = publicConfigQuery.data?.cap_auto_solve ?? true
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: (req: any) => services.auth.login(req),
|
||||
mutationFn: (req: LoginRequest) => {
|
||||
const headers: Record<string, string> = {}
|
||||
if (capEnabled && capTokenRef.current) {
|
||||
headers["X-Cap-Token"] = capTokenRef.current
|
||||
// Consume the token — next login attempt will need a new one
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return services.auth.login(req, Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
router.replace(redirectTarget)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || "登录失败,请重试")
|
||||
toast.error(error.message || "登录失败,请重试")
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: (req: any) => services.auth.register(req),
|
||||
mutationFn: (req: RegisterRequest) => services.auth.register(req),
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
router.replace(redirectTarget)
|
||||
@@ -84,8 +109,23 @@ export function LoginForm() {
|
||||
|
||||
const handlePasswordLogin = () => {
|
||||
setErrorMessage("")
|
||||
const trimmedUsername = username.trim()
|
||||
if (!trimmedUsername || !password) {
|
||||
toast.error("账号或密码未填写完整", {
|
||||
description: "请先输入账号和密码后再登录",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (capEnabled && !capReady) {
|
||||
toast.error(
|
||||
capAutoSolve
|
||||
? "人机验证尚未完成,请稍候…"
|
||||
: "请先点击「开始验证」完成人机验证",
|
||||
)
|
||||
return
|
||||
}
|
||||
loginMutation.mutate({
|
||||
username: username.trim(),
|
||||
username: trimmedUsername,
|
||||
password,
|
||||
})
|
||||
}
|
||||
@@ -110,6 +150,18 @@ export function LoginForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCapToken = (token: string) => {
|
||||
capTokenRef.current = token
|
||||
setCapReady(true)
|
||||
setCapError(false)
|
||||
}
|
||||
|
||||
const handleCapError = () => {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapError(true)
|
||||
}
|
||||
|
||||
const registrationEnabled =
|
||||
(publicConfigQuery.data?.registration_enabled ?? true) &&
|
||||
(publicConfigQuery.data?.password_register_enabled ?? true)
|
||||
@@ -118,6 +170,16 @@ export function LoginForm() {
|
||||
|
||||
const authSources = authSourcesQuery.data ?? []
|
||||
|
||||
// Login button disabled when:
|
||||
// - password login is off, OR
|
||||
// - login mutation is pending, OR
|
||||
// - cap is enabled AND auto-solve mode AND not yet solved (and not in error state)
|
||||
// When autoStart=false (manual mode), idle means the user hasn't clicked yet — don't block.
|
||||
const loginDisabled =
|
||||
!passwordLoginEnabled ||
|
||||
loginMutation.isPending ||
|
||||
(capEnabled && capAutoSolve && !capReady && !capError)
|
||||
|
||||
return (
|
||||
<Card className="w-full border-border/60 bg-background/80 shadow-2xl backdrop-blur">
|
||||
<CardContent className="space-y-5 p-5 sm:p-6">
|
||||
@@ -143,6 +205,7 @@ export function LoginForm() {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
|
||||
/>
|
||||
<Input
|
||||
value={password}
|
||||
@@ -150,21 +213,26 @@ export function LoginForm() {
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Cap 人机验证 */}
|
||||
{capEnabled && (
|
||||
<CapWidget
|
||||
key={capResetKey}
|
||||
onToken={handleCapToken}
|
||||
onError={handleCapError}
|
||||
autoStart={capAutoSolve}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={"secondary"}
|
||||
onClick={handlePasswordLogin}
|
||||
disabled={!passwordLoginEnabled || loginMutation.isPending}
|
||||
disabled={loginDisabled}
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
@@ -238,7 +306,7 @@ export function LoginForm() {
|
||||
|
||||
{authSources.length > 0 ? (
|
||||
authSources.map((source) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3" key={source.id}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ShieldCheck className="size-4" />
|
||||
第三方认证源
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Activity, Cpu, Database, HardDrive, Layers, RefreshCw} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import type {SystemStatus} from "@/lib/services"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时系统状态展示与管理组件
|
||||
*/
|
||||
export function SystemStatusManager() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevStatusRef = useRef<SystemStatus | null>(null)
|
||||
const [changedFields, setChangedFields] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 获取状态数据
|
||||
const fetchStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setRefreshing(true)
|
||||
try {
|
||||
const data = await services.admin.getSystemStatus()
|
||||
|
||||
// 检测变化的字段用于微动画效果
|
||||
if (prevStatusRef.current) {
|
||||
const changes: Record<string, boolean> = {}
|
||||
Object.keys(data).forEach((key) => {
|
||||
const k = key as keyof SystemStatus
|
||||
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
|
||||
changes[k] = true
|
||||
}
|
||||
})
|
||||
setChangedFields(changes)
|
||||
// 1秒后清除变化状态
|
||||
setTimeout(() => {
|
||||
setChangedFields({})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
prevStatusRef.current = data
|
||||
} catch (err) {
|
||||
toast.error("获取系统状态失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
// 首次加载
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const time = parseInt(intervalTime, 10)
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchStatus(true)
|
||||
}, time)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, intervalTime, fetchStatus])
|
||||
|
||||
// 计算内存分配百分比 (Alloc / Sys)
|
||||
const getMemoryUsagePercent = () => {
|
||||
if (!status) return 0
|
||||
|
||||
const parseSizeToBytes = (sizeStr: string): number => {
|
||||
const parts = sizeStr.split(" ")
|
||||
if (parts.length < 2) return 0
|
||||
const value = parseFloat(parts[0])
|
||||
const unit = parts[1].toLowerCase()
|
||||
|
||||
if (unit.startsWith("gib")) return value * 1024 * 1024 * 1024
|
||||
if (unit.startsWith("mib")) return value * 1024 * 1024
|
||||
if (unit.startsWith("kib")) return value * 1024
|
||||
return value
|
||||
}
|
||||
|
||||
const alloc = parseSizeToBytes(status.alloc)
|
||||
const sys = parseSizeToBytes(status.sys)
|
||||
if (sys === 0) return 0
|
||||
return Math.min(Math.round((alloc / sys) * 100), 100)
|
||||
}
|
||||
|
||||
// 闪烁动画类
|
||||
const getPulseClass = (field: string) => {
|
||||
return changedFields[field] ? "animate-pulse text-primary font-bold transition-all duration-300 scale-105 inline-block" : "transition-all duration-300"
|
||||
}
|
||||
|
||||
const RenderMetricRow = ({ label, value, field }: { label: string; value: string | number; field?: string }) => (
|
||||
<div className="flex items-center justify-between py-2 border-b border-dashed border-border/40 last:border-b-0 hover:bg-muted/30 px-2 rounded-sm transition-colors duration-150">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ""}`}>
|
||||
{typeof value === "number" ? formatNumber(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 p-1">
|
||||
<div className="flex items-center justify-between border-b border-border/40 pb-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-1">
|
||||
{/* 顶部控制栏 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-border/30 pb-4 gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">系统状态</h2>
|
||||
<p className="text-xs text-muted-foreground">监控后端服务的核心运行时状态与内存指标</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-4 bg-muted/30 p-1.5 rounded-lg border border-border/40 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<label htmlFor="auto-refresh" className="text-xs font-medium cursor-pointer select-none">
|
||||
自动刷新
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{autoRefresh && (
|
||||
<Select value={intervalTime} onValueChange={setIntervalTime}>
|
||||
<SelectTrigger className="h-7 w-20 text-[11px] bg-background border-border/40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1000">1s</SelectItem>
|
||||
<SelectItem value="2000">2s</SelectItem>
|
||||
<SelectItem value="5000">5s</SelectItem>
|
||||
<SelectItem value="10000">10s</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-[11px] gap-1.5"
|
||||
onClick={() => fetchStatus()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<>
|
||||
{/* 主网格排版 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* 1. 服务概览 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">服务概览</CardTitle>
|
||||
<CardDescription className="text-[10px]">服务的基础生命指标</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Activity className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="服务运行时间" value={status.uptime} field="uptime" />
|
||||
<RenderMetricRow label="当前 Goroutines 数量" value={status.num_goroutine} field="num_goroutine" />
|
||||
<RenderMetricRow label="Heap 对象数量" value={status.heap_objects} field="heap_objects" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. 内存统计 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">内存统计</CardTitle>
|
||||
<CardDescription className="text-[10px]">主内存消耗概览</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Cpu className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<RenderMetricRow label="当前内存使用量" value={status.alloc} field="alloc" />
|
||||
<RenderMetricRow label="所有已分配的内存" value={status.total_alloc} field="total_alloc" />
|
||||
<RenderMetricRow label="内存占用量" value={status.sys} field="sys" />
|
||||
<RenderMetricRow label="下次 GC 内存回收量" value={status.next_gc} field="next_gc" />
|
||||
</div>
|
||||
{/* 物理内存水位比例 */}
|
||||
<div className="space-y-1 px-1 pt-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>当前使用率 (Alloc / Sys)</span>
|
||||
<span className="font-mono">{getMemoryUsagePercent()}%</span>
|
||||
</div>
|
||||
<Progress value={getMemoryUsagePercent()} className="h-1.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. 堆/栈详情 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">堆/栈详情</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时堆栈内存空间细节</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Database className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="当前 Heap 内存使用量" value={status.heap_alloc} field="heap_alloc" />
|
||||
<RenderMetricRow label="Heap 内存占用量" value={status.heap_sys} field="heap_sys" />
|
||||
<RenderMetricRow label="Heap 内存空闲量" value={status.heap_idle} field="heap_idle" />
|
||||
<RenderMetricRow label="正在使用的 Heap 内存" value={status.heap_inuse} field="heap_inuse" />
|
||||
<RenderMetricRow label="已释放的 Heap 内存" value={status.heap_released} field="heap_released" />
|
||||
<RenderMetricRow label="启动 Stack 使用量" value={status.stack_inuse} field="stack_inuse" />
|
||||
<RenderMetricRow label="已分配的 Stack 内存" value={status.stack_sys} field="stack_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. 底层组件与结构体 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">底层及结构体内存</CardTitle>
|
||||
<CardDescription className="text-[10px]">运行时底层管理结构体开销</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<HardDrive className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<RenderMetricRow label="MSpan 结构内存使用量" value={status.mspan_inuse} field="mspan_inuse" />
|
||||
<RenderMetricRow label="已分配的 MSpan 结构内存" value={status.mspan_sys} field="mspan_sys" />
|
||||
<RenderMetricRow label="MCache 结构内存使用量" value={status.mcache_inuse} field="mcache_inuse" />
|
||||
<RenderMetricRow label="已分配的 MCache 结构内存" value={status.mcache_sys} field="mcache_sys" />
|
||||
<RenderMetricRow label="已分配的剖析哈希表内存" value={status.buck_hash_sys} field="buck_hash_sys" />
|
||||
<RenderMetricRow label="已分配的 GC 元数据内存" value={status.gc_sys} field="gc_sys" />
|
||||
<RenderMetricRow label="其它已分配的系统内存" value={status.other_sys} field="other_sys" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 5. 垃圾回收与分配计数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-md hover:border-primary/20 transition-all duration-300 md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">垃圾回收与分配计数</CardTitle>
|
||||
<CardDescription className="text-[10px]">GC 历史数据与分配频次</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="p-1 rounded-full bg-primary/10 text-primary border-none">
|
||||
<Layers className="size-4" />
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
|
||||
<div>
|
||||
<RenderMetricRow label="GC 执行次数" value={status.num_gc} field="num_gc" />
|
||||
<RenderMetricRow label="距离上次 GC 时间" value={status.last_gc_time} field="last_gc_time" />
|
||||
<RenderMetricRow label="GC 暂停时间总量" value={status.pause_total_ns} field="pause_total_ns" />
|
||||
<RenderMetricRow label="上次 GC 暂停时间" value={status.last_pause} field="last_pause" />
|
||||
</div>
|
||||
<div>
|
||||
<RenderMetricRow label="内存分配次数" value={status.mallocs} field="mallocs" />
|
||||
<RenderMetricRow label="内存释放次数" value={status.frees} field="frees" />
|
||||
<RenderMetricRow label="指针查找次数" value={status.lookups} field="lookups" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,17 +3,16 @@
|
||||
import {useEffect, useMemo, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
CalendarClock,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
Info,
|
||||
Loader2,
|
||||
Lock,
|
||||
Monitor,
|
||||
Pencil,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus
|
||||
} from "lucide-react"
|
||||
@@ -25,11 +24,14 @@ import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import {AdminService, apiConfig} from "@/lib/services"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
import {SystemStatusManager} from "@/components/common/admin/status"
|
||||
|
||||
const SECURITY_KEYS = [
|
||||
{
|
||||
@@ -76,10 +78,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatBooleanConfig(config?: SystemConfig) {
|
||||
if (!config) return "未配置"
|
||||
return config.value === "true" ? "启用" : "禁用"
|
||||
}
|
||||
|
||||
|
||||
export function SecurityMain() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -87,13 +86,13 @@ export function SecurityMain() {
|
||||
const router = useRouter()
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||
const [runtimeInfo, setRuntimeInfo] = useState({
|
||||
language: "-",
|
||||
platform: "-",
|
||||
timezone: "-",
|
||||
viewport: "-",
|
||||
userAgent: "-",
|
||||
})
|
||||
|
||||
const [capCount, setCapCount] = useState("")
|
||||
const [capDifficulty, setCapDifficulty] = useState("")
|
||||
const [capSize, setCapSize] = useState("")
|
||||
const [capTTL, setCapTTL] = useState("")
|
||||
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
@@ -119,17 +118,19 @@ export function SecurityMain() {
|
||||
}, [user, loading, router])
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeInfo({
|
||||
language: navigator.language || "-",
|
||||
platform: navigator.platform || "-",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "-",
|
||||
viewport: `${window.innerWidth} x ${window.innerHeight}`,
|
||||
userAgent: navigator.userAgent || "-",
|
||||
})
|
||||
}, [])
|
||||
if (systemConfigsQuery.data) {
|
||||
const cfgMap = systemConfigMap(systemConfigsQuery.data)
|
||||
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
|
||||
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
|
||||
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
|
||||
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
|
||||
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||
}
|
||||
}, [systemConfigsQuery.data])
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => {
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
@@ -176,10 +177,43 @@ export function SecurityMain() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: SecurityKey, checked: boolean) => {
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
updateConfigMutation.mutate({ key, value: checked })
|
||||
}
|
||||
|
||||
const saveCapMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "cap_challenge_count", value: capCount },
|
||||
{ key: "cap_challenge_difficulty", value: capDifficulty },
|
||||
{ key: "cap_challenge_size", value: capSize },
|
||||
{ key: "cap_challenge_ttl_seconds", value: capTTL },
|
||||
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
|
||||
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
await AdminService.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("人机验证配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCapSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveCapMutation.mutate()
|
||||
}
|
||||
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -198,7 +232,7 @@ export function SecurityMain() {
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
<TabsList className="w-full overflow-x-auto">
|
||||
<TabsTrigger value="security" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统安全设置
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operation" className="px-0 pb-2 text-xs font-semibold">
|
||||
运营设置
|
||||
@@ -206,6 +240,9 @@ export function SecurityMain() {
|
||||
<TabsTrigger value="system" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="status" className="px-0 pb-2 text-xs font-semibold">
|
||||
系统状态
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other" className="px-0 pb-2 text-xs font-semibold">
|
||||
其他设置
|
||||
</TabsTrigger>
|
||||
@@ -376,10 +413,138 @@ export function SecurityMain() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 人机验证配置 (Cap CAPTCHA) */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Shield className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
|
||||
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={configs["cap_login_enabled"]?.value === "true"}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{/* 自动开始计算 Switch */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={capAutoSolve}
|
||||
onCheckedChange={setCapAutoSolve}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCapSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
|
||||
<Input
|
||||
id="cap_challenge_count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={capCount}
|
||||
onChange={(e) => setCapCount(e.target.value)}
|
||||
placeholder="50"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
|
||||
<Input
|
||||
id="cap_challenge_difficulty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={capDifficulty}
|
||||
onChange={(e) => setCapDifficulty(e.target.value)}
|
||||
placeholder="4"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
|
||||
<Input
|
||||
id="cap_challenge_size"
|
||||
type="number"
|
||||
min={8}
|
||||
max={64}
|
||||
value={capSize}
|
||||
onChange={(e) => setCapSize(e.target.value)}
|
||||
placeholder="32"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_challenge_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTTL}
|
||||
onChange={(e) => setCapTTL(e.target.value)}
|
||||
placeholder="600"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_token_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTokenTTL}
|
||||
onChange={(e) => setCapTokenTTL(e.target.value)}
|
||||
placeholder="1200"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveCapMutation.isPending}
|
||||
>
|
||||
{saveCapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="operation" />
|
||||
<TabsContent value="system" />
|
||||
<TabsContent value="status" className="pt-4">
|
||||
<SystemStatusManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" />
|
||||
<TabsContent value="info" className="pt-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
@@ -424,48 +589,6 @@ export function SecurityMain() {
|
||||
<InfoRow label="认证源数量" value={`${authSourcesQuery.data?.length ?? 0} 个`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||
<Monitor className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">运行环境</CardTitle>
|
||||
<CardDescription className="text-xs">当前浏览器会话的本地运行信息</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<InfoRow label="语言" value={runtimeInfo.language} />
|
||||
<InfoRow label="平台" value={runtimeInfo.platform} />
|
||||
<InfoRow label="时区" value={runtimeInfo.timezone} />
|
||||
<InfoRow label="视口" value={runtimeInfo.viewport} />
|
||||
<InfoRow label="User Agent" value={runtimeInfo.userAgent} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||
<CalendarClock className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">安全配置概览</CardTitle>
|
||||
<CardDescription className="text-xs">当前系统登录与注册开关状态</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<InfoRow label="密码登录" value={formatBooleanConfig(configs.password_login_enabled)} />
|
||||
<InfoRow label="开放注册" value={formatBooleanConfig(configs.registration_enabled)} />
|
||||
<InfoRow label="密码注册" value={formatBooleanConfig(configs.password_register_enabled)} />
|
||||
<InfoRow label="OIDC 登录" value={formatBooleanConfig(configs.oidc_login_enabled)} />
|
||||
<InfoRow label="配置加载状态" value={systemConfigsQuery.isFetching ? "刷新中" : "已加载"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -67,16 +67,17 @@ const data = {
|
||||
{ title: "首页", url: "/home", icon: Home },
|
||||
],
|
||||
admin: [
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
|
||||
{ title: "用户管理", url: "/admin/users", icon: UserRound },
|
||||
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
|
||||
{ title: "文件管理", url: "/admin/files", icon: FolderOpen },
|
||||
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
|
||||
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
|
||||
],
|
||||
document: [
|
||||
{ title: "组件库", url: "/components", icon: Palette },
|
||||
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
|
||||
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
|
||||
{ title: "组件库", url: "/components", icon: Palette },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Cap PoW (Proof-of-Work) 人机验证前端实现
|
||||
* 与后端 internal/util/cap 算法完全对应
|
||||
*
|
||||
* 求解在 Web Worker 中执行,不阻塞主线程 UI。
|
||||
*/
|
||||
|
||||
// ——— Challenge / Redeem API types ———
|
||||
|
||||
export interface ChallengeResponse {
|
||||
challenge: { c: number; s: number; d: number };
|
||||
token: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export interface RedeemResponse {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
expires?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ——— Worker 代码(内联 Blob,避免独立文件的打包配置问题)———
|
||||
//
|
||||
// 算法与 Go 后端 internal/util/cap/cap.go + prng.go 完全对应:
|
||||
// · FNV-1a 32-bit (Math.imul 保证 32-bit 截断)
|
||||
// · xorshift32 PRNG → hex 字符串
|
||||
// · SubtleCrypto SHA-256 校验答案
|
||||
|
||||
const WORKER_SOURCE = /* js */ `
|
||||
// FNV-1a 32-bit
|
||||
function fnv1a(str) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function fnv1aResume(state, str) {
|
||||
let h = state >>> 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// xorshift32 PRNG → hex string
|
||||
function prngFromHash(seed, len) {
|
||||
let s = seed >>> 0, r = '';
|
||||
while (r.length < len) {
|
||||
s ^= (s << 13) >>> 0;
|
||||
s ^= (s >>> 17) >>> 0;
|
||||
s ^= (s << 5) >>> 0;
|
||||
s = s >>> 0;
|
||||
r += s.toString(16).padStart(8, '0');
|
||||
}
|
||||
return r.slice(0, len);
|
||||
}
|
||||
|
||||
// SHA-256 via SubtleCrypto (available in workers)
|
||||
async function sha256Hex(input) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Solve one puzzle
|
||||
async function solvePuzzle(token, index, size, difficulty) {
|
||||
const tFnv = fnv1a(token);
|
||||
const saltSeed = fnv1aResume(tFnv, String(index + 1));
|
||||
const targetSeed = fnv1aResume(saltSeed, 'd');
|
||||
const salt = prngFromHash(saltSeed, size);
|
||||
const target = prngFromHash(targetSeed, difficulty);
|
||||
|
||||
for (let nonce = 0; nonce < 10_000_000; nonce++) {
|
||||
if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;
|
||||
}
|
||||
throw new Error('无法在合理范围内求解第 ' + (index + 1) + ' 个难题');
|
||||
}
|
||||
|
||||
// Entry point — receive task from main thread
|
||||
self.onmessage = async ({ data }) => {
|
||||
const { token, count, size, difficulty } = data;
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const solutions = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
solutions.push(await solvePuzzle(token, i, size, difficulty));
|
||||
}
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(3);
|
||||
self.postMessage({ type: 'done', solutions, elapsed });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
// ——— 在 Web Worker 中求解,返回 Promise<number[]> ———
|
||||
|
||||
function solveInWorker(
|
||||
token: string,
|
||||
count: number,
|
||||
size: number,
|
||||
difficulty: number,
|
||||
): Promise<{ solutions: number[]; elapsed: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
URL.revokeObjectURL(url);
|
||||
worker.terminate();
|
||||
if (e.data.type === 'done') {
|
||||
resolve({ solutions: e.data.solutions, elapsed: e.data.elapsed });
|
||||
} else {
|
||||
reject(new Error(e.data.message));
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
URL.revokeObjectURL(url);
|
||||
worker.terminate();
|
||||
reject(new Error(err.message || 'Worker 执行失败'));
|
||||
};
|
||||
|
||||
worker.postMessage({ token, count, size, difficulty });
|
||||
});
|
||||
}
|
||||
|
||||
// ——— Full Cap flow: get challenge → solve (Worker) → redeem → return X-Cap-Token ———
|
||||
|
||||
export async function getCapToken(scope = 'login'): Promise<string> {
|
||||
// 1. 获取难题
|
||||
const challengeRes = await fetch('/api/cap/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
throw new Error('获取人机验证难题失败');
|
||||
}
|
||||
const challenge: ChallengeResponse = await challengeRes.json();
|
||||
const { c: count, s: size, d: difficulty } = challenge.challenge;
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解开始');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` 盐值长度 (size) : ${size}`);
|
||||
console.groupEnd();
|
||||
|
||||
// 2. 在 Worker 中求解(不阻塞主线程)
|
||||
const t0 = performance.now();
|
||||
const { solutions, elapsed } = await solveInWorker(
|
||||
challenge.token,
|
||||
count,
|
||||
size,
|
||||
difficulty,
|
||||
);
|
||||
const wallTime = ((performance.now() - t0) / 1000).toFixed(3);
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解完成');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` Worker 耗时 : ${elapsed}s`);
|
||||
console.log(` 总耗时(含通信) : ${wallTime}s`);
|
||||
console.log(` Solutions :`, solutions);
|
||||
console.groupEnd();
|
||||
|
||||
// 3. 提交答案兑换一次性凭证
|
||||
const redeemRes = await fetch('/api/cap/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: challenge.token, solutions, scope }),
|
||||
});
|
||||
const redeemData: RedeemResponse = await redeemRes.json();
|
||||
if (!redeemData.success || !redeemData.token) {
|
||||
throw new Error(redeemData.error || '人机验证失败');
|
||||
}
|
||||
|
||||
return redeemData.token;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
SystemConfig,
|
||||
SystemStatus,
|
||||
TaskExecution,
|
||||
TaskMeta,
|
||||
TaskTypeResponse,
|
||||
@@ -318,4 +319,14 @@ export class AdminService extends BaseService {
|
||||
static async createUser(request: CreateUserRequest): Promise<AdminUser> {
|
||||
return this.post<AdminUser>('/users', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
* @returns 系统状态指标数据
|
||||
* @throws {UnauthorizedError} 当未登录时
|
||||
* @throws {ForbiddenError} 当无管理员权限时
|
||||
*/
|
||||
static async getSystemStatus(): Promise<SystemStatus> {
|
||||
return this.get<SystemStatus>('/status');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,5 @@ export type {
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
SystemStatus,
|
||||
} from './types';
|
||||
|
||||
@@ -254,3 +254,37 @@ export interface AuthSourceRequest {
|
||||
export interface ToggleAuthSourceRequest {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统状态信息
|
||||
*/
|
||||
export interface SystemStatus {
|
||||
uptime: string;
|
||||
num_goroutine: number;
|
||||
alloc: string;
|
||||
total_alloc: string;
|
||||
sys: string;
|
||||
lookups: number;
|
||||
mallocs: number;
|
||||
frees: number;
|
||||
heap_alloc: string;
|
||||
heap_sys: string;
|
||||
heap_idle: string;
|
||||
heap_inuse: string;
|
||||
heap_released: string;
|
||||
heap_objects: number;
|
||||
stack_inuse: string;
|
||||
stack_sys: string;
|
||||
mspan_inuse: string;
|
||||
mspan_sys: string;
|
||||
mcache_inuse: string;
|
||||
mcache_sys: string;
|
||||
buck_hash_sys: string;
|
||||
gc_sys: string;
|
||||
other_sys: string;
|
||||
next_gc: string;
|
||||
last_gc_time: string;
|
||||
pause_total_ns: string;
|
||||
last_pause: string;
|
||||
num_gc: number;
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ export class AuthService extends BaseService {
|
||||
await this.get<void>('/oauth/logout');
|
||||
}
|
||||
|
||||
static async login(request: LoginRequest): Promise<User> {
|
||||
return this.post<User>('/user/login', request);
|
||||
static async login(request: LoginRequest, headers?: Record<string, string>): Promise<User> {
|
||||
return this.post<User>('/user/login', request, headers ? { headers } as any : undefined);
|
||||
}
|
||||
|
||||
static async register(request: RegisterRequest): Promise<User> {
|
||||
|
||||
@@ -16,4 +16,8 @@ export interface PublicConfigResponse {
|
||||
oidc_login_enabled: boolean;
|
||||
/** 每个用户最大 API Key 数量 */
|
||||
max_api_keys_per_user: number;
|
||||
/** 是否启用 Cap 人机验证 */
|
||||
cap_login_enabled: boolean;
|
||||
/** 是否自动解题 */
|
||||
cap_auto_solve: boolean;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import axios, { AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig } from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { apiConfig } from './config';
|
||||
import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import {apiConfig} from './config';
|
||||
import {
|
||||
ApiErrorBase,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
ForbiddenError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
ValidationError,
|
||||
} from './errors';
|
||||
import { ApiError, ApiResponse } from './types';
|
||||
import {ApiError, ApiResponse} from './types';
|
||||
|
||||
/**
|
||||
* API 客户端实例
|
||||
@@ -102,6 +102,11 @@ apiClient.interceptors.response.use(
|
||||
cancelTokens.delete(requestKey);
|
||||
pendingRequests.delete(requestKey);
|
||||
|
||||
const resData = response.data as any;
|
||||
if (resData && resData.error_msg) {
|
||||
return Promise.reject(new ApiErrorBase(resData.error_msg));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError<ApiError>) => {
|
||||
|
||||
@@ -105,6 +105,7 @@ export type {
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
SystemStatus,
|
||||
} from './admin';
|
||||
|
||||
// 用户服务
|
||||
|
||||
Reference in New Issue
Block a user