fix(frontend): optimize

This commit is contained in:
ryan
2026-06-17 13:47:12 +08:00
parent 3a05e7f09c
commit 333e45572a
8 changed files with 137 additions and 64 deletions
+2 -12
View File
@@ -32,12 +32,12 @@
## 严格遵循事项 (Guardrails)
- 切勿删除 `frontend/node_modules`;如果需要刷新依赖,请使用 `pnpm install` 重新安装。
- 切勿删除 `frontend/node_modules`
- 保持 `internal/util/` 绝对纯净且不引入任何框架。禁止从 `internal/util/` 及其子包中导入 Gin、GORM、sessions 等 HTTP/Web/数据库相关框架包(例如,Web 会话选项已收敛至 `internal/apps/oauth/session.go`)。
- 编写测试用例时,禁止使用硬编码的相对路径(如 `"uploads/test_cache"`)在源码目录下创建临时测试目录,必须统一使用 Go 内置的 `t.TempDir()` 以避免污染源码目录。
- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。
- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。
- 在提交更改前运行 `make code-check`。
- 在完成代码开发后必须运行 `make code-check`, 并修复报错。
- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。
## 项目介绍
@@ -161,16 +161,6 @@ Handler 规范:
- 不要创建物理数据库外键。改为关系字段添加显式索引。
- 数据库默认值必须与 Go 模型零值(`nil`、`0`、`false`、`""`)匹配,以避免意外的插入。
严格依赖防线:
- `internal/util/` 及其子包必须保持无框架依赖。
- 不要从 `internal/util/` 中导入 `github.com/gin-gonic/gin`、`gorm.io/gorm`、`github.com/gin-contrib/sessions` 或 HTTP 中间件/框架包。
- 如果实用工具逻辑需要 web 胶水,请将纯验证/计算保留在 `internal/util/` 中,并将 Gin 中间件/响应处理放在 `internal/apps/` 中。
新增接口与模块开发工作流:
- 关于自定义业务接口(如 Admin/User/Custom 模块等)的详细包职责、文件结构和核心开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/Wavelet/.agent/skills/new-api/SKILL.md) 技能。
### 前端规则
在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。
+2 -1
View File
@@ -7514,7 +7514,8 @@ const docTemplate = `{
"type": "string"
},
"id": {
"type": "integer"
"type": "string",
"example": "0"
},
"is_active": {
"type": "boolean"
+2 -1
View File
@@ -7507,7 +7507,8 @@
"type": "string"
},
"id": {
"type": "integer"
"type": "string",
"example": "0"
},
"is_active": {
"type": "boolean"
+2 -1
View File
@@ -1379,7 +1379,8 @@ definitions:
gender:
type: string
id:
type: integer
example: "0"
type: string
is_active:
type: boolean
is_admin:
+7 -44
View File
@@ -19,12 +19,11 @@ limitations under the License.
import {memo, useCallback, useEffect, useRef, useState} from "react"
import {useVirtualizer} from "@tanstack/react-virtual"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
import {AdminService} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
interface LogEntry {
@@ -48,7 +47,7 @@ const LogLine = memo(function LogLine({data}: {data: string}) {
: "text-gray-300"
return (
<div className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}>
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
{data}
</div>
)
@@ -59,7 +58,7 @@ function getApiBaseUrl(): string {
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
if (base.startsWith("http")) return base
// Relative URL fallback
const proto = window.location.protocol
const host = window.location.host
@@ -92,19 +91,14 @@ export function AppLogs() {
const [nextCursor, setNextCursor] = useState(0)
const [loadingMore, setLoadingMore] = useState(false)
const [connected, setConnected] = useState(false)
const [paused, setPaused] = useState(false)
// autoScroll = true → new logs auto-scroll to bottom
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const pausedRef = useRef(paused)
const autoScrollRef = useRef(autoScroll)
const isUserScrolling = useRef(false)
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
const rowVirtualizer = useVirtualizer({
@@ -213,10 +207,7 @@ export function AppLogs() {
const ws = new WebSocket(buildWsUrl())
wsRef.current = ws
ws.onopen = () => { setConnected(true) }
ws.onmessage = (event) => {
if (pausedRef.current) return
try {
const msg = JSON.parse(event.data)
if (msg.type === "log" && msg.data) {
@@ -229,8 +220,7 @@ export function AppLogs() {
} catch { /* ignore */ }
}
ws.onclose = () => { setConnected(false); wsRef.current = null }
ws.onerror = () => { setConnected(false) }
ws.onclose = () => { wsRef.current = null }
}, [])
// ---- Initialize ------------------------------------------------------
@@ -253,8 +243,6 @@ export function AppLogs() {
})
}, [])
const togglePause = useCallback(() => setPaused(p => !p), [])
const reconnect = useCallback(() => connectWs(), [connectWs])
const handleLoadMore = useCallback(() => {
if (nextCursor > 0) loadHistory(nextCursor)
}, [nextCursor, loadHistory])
@@ -265,38 +253,13 @@ export function AppLogs() {
return (
<div className="flex flex-col h-full relative">
{/* Sub Header / Control Bar */}
<div className="flex items-center justify-between pb-3 border-b border-border/40 mb-3">
<div className="text-sm text-muted-foreground font-medium">
系统后台实时输出的运行日志 (最多缓存 2000 行)
</div>
<div className="flex items-center gap-2">
<Badge variant={connected ? "secondary" : "destructive"} className="h-6">
{connected ? "已连接" : "断开连接"}
</Badge>
{connected && (
<Button variant="outline" size="sm" onClick={togglePause} className="h-8">
{paused
? <><Play className="size-3.5 mr-1.5" />恢复</>
: <><Pause className="size-3.5 mr-1.5" />暂停</>
}
</Button>
)}
{!connected && (
<Button variant="outline" size="sm" onClick={reconnect} className="h-8">
重新连接
</Button>
)}
</div>
</div>
{/* Log viewer — fixed height, scrollable */}
<div
ref={containerRef}
onScroll={handleScroll}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-md border border-border/50 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
>
{/* Load older logs */}
{hasMore && (
@@ -317,10 +280,10 @@ export function AppLogs() {
)}
{logs.length === 0 ? (
<div className="px-3 py-8 text-center text-gray-500">暂无日志</div>
<div className="px-4 py-8 text-center text-gray-500">暂无日志</div>
) : (
<div
className="relative px-3 py-2"
className="relative px-3 py-3"
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
+26 -2
View File
@@ -4,15 +4,19 @@
// Package auth_source 提供认证源管理功能
package auth_source
import ("errors"
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/admin"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/Rain-kl/Wavelet/internal/common/response")
"github.com/Rain-kl/Wavelet/internal/common/response"
)
// AuthSourceRequest 创建或更新认证源的请求参数
type AuthSourceRequest struct {
@@ -119,6 +123,9 @@ func UpdateAuthSource(c *gin.Context) {
return
}
// 记录更新前的 Discovery URL,以便更新成功后清除旧缓存条目。
existing, _ := model.GetAuthSourceByID(c.Request.Context(), id)
source := model.AuthSource{
ID: id,
Name: req.Name,
@@ -136,6 +143,14 @@ func UpdateAuthSource(c *gin.Context) {
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
return
}
// Discovery URL 可能已变更,清除旧、新 issuer 的 provider 缓存,
// 确保下次登录时重新拉取最新 OIDC 元数据。
if existing != nil {
oauth.InvalidateOIDCProviderCache(normalizeIssuer(existing.OpenIDDiscoveryURL))
}
oauth.InvalidateOIDCProviderCache(normalizeIssuer(req.OpenIDDiscoveryURL))
updated, err := model.GetAuthSourceByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
@@ -219,3 +234,12 @@ func parseSourceID(c *gin.Context) (uint64, error) {
}
return id, nil
}
// normalizeIssuer 将 Discovery URL 规范化为 issuer 基础 URL,
// 与 oauth.buildOAuthConfig 中的规范化逻辑保持一致。
func normalizeIssuer(discoveryURL string) string {
issuer := strings.TrimSuffix(strings.TrimSpace(discoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
return issuer
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package oauth
import (
"context"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/sync/singleflight"
)
// oidcProviderCache 进程级 OIDC provider 缓存。
//
// oidc.NewProvider 每次调用都会向远端 issuer 的
// /.well-known/openid-configuration 发起 HTTP 请求拉取元数据。
// 由于 provider 元数据极少变动,将其缓存后可消除登录发起与回调时的
// 重复外部 HTTP 往返。
//
// 并发安全性:
// - mu + entries 防止并发读写 map。
// - sfGroup 保证同一 issuer 同时只有一次在途的 NewProvider 调用
// (singleflight),后续等待者复用同一结果,彻底消除 thundering herd。
type oidcProviderCache struct {
mu sync.RWMutex
entries map[string]*oidc.Provider // key: normalized issuer URL
sfGroup singleflight.Group
}
// globalOIDCProviderCache 是包级单例缓存,与进程同生命周期。
var globalOIDCProviderCache = &oidcProviderCache{
entries: make(map[string]*oidc.Provider),
}
// get 返回缓存的 provider;若无则通过 oidc.NewProvider 获取并写入缓存。
// 同一 issuer 并发调用时,singleflight 保证只有一次实际 HTTP 请求。
//
// 注意:接受 _ 形参以与调用方类型一致,内部有意使用 context.Background() 而非传入的请求 ctx,
// 以防止请求被提前取消时导致缓存写入失败。
func (c *oidcProviderCache) get(_ context.Context, issuer string) (*oidc.Provider, error) { //nolint:contextcheck // intentional: use Background to avoid request cancellation affecting cache write
// 快路径:已有缓存则直接返回。
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
// 慢路径:通过 singleflight 合并并发的首次请求。
// 闭包内有意使用 context.Background() 而非请求 ctx,防止请求取消导致缓存写入失败。
v, err, _ := c.sfGroup.Do(issuer, func() (any, error) { //nolint:contextcheck // intentional: Background ctx prevents cache write failure on request cancellation
// 双检:singleflight 内再次检查,前一个并发组可能已写入缓存。
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
p, err := oidc.NewProvider(context.Background(), issuer)
if err != nil {
return nil, err
}
c.mu.Lock()
c.entries[issuer] = p
c.mu.Unlock()
return p, nil
})
if err != nil {
return nil, err
}
return v.(*oidc.Provider), nil //nolint:forcetypeassert // singleflight value 由同函数写入,类型确定
}
// invalidate 从缓存中移除指定 issuer 对应的 provider。
// 在认证源的 Discovery URL 被修改时调用,强制下次请求重新拉取元数据。
func (c *oidcProviderCache) invalidate(issuer string) {
c.mu.Lock()
delete(c.entries, issuer)
c.mu.Unlock()
}
// InvalidateOIDCProviderCache 从进程级缓存中清除指定 issuer 的 provider 条目。
// 当管理员更新认证源的 Discovery URL 后调用,以确保下次登录时重新拉取最新元数据。
// issuer 值应为去掉 /.well-known/openid-configuration 后缀的规范化 URL。
func InvalidateOIDCProviderCache(issuer string) {
globalOIDCProviderCache.invalidate(issuer)
}
+6 -3
View File
@@ -3,7 +3,8 @@
package oauth
import ("context"
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -15,6 +16,7 @@ import ("context"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -25,7 +27,6 @@ import ("context"
"github.com/google/uuid"
"golang.org/x/oauth2"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/internal/common/response"
)
// AuthSourceView 登录源展示信息
@@ -161,7 +162,9 @@ func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := oidc.NewProvider(ctx, issuer)
// 使用进程级缓存获取 provider,避免每次调用都向 issuer 发起
// /.well-known/openid-configuration HTTP 请求。
provider, err := globalOIDCProviderCache.get(ctx, issuer)
if err != nil {
return nil, nil, err
}