feat(logs): 日志页搜索合并为单个搜索框

后端新增 /logs?kw=:命中提示词/模型/错误,或命中用户
(名称/邮箱/ID) 的记录均保留;前端两个搜索框合并为一个。
首页导航恢复 ✦ 品牌小星标,与其他页一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-27 16:43:34 +08:00
co-authored by Devin
parent cce126cd52
commit 539c251f9f
5 changed files with 68 additions and 45 deletions
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
@@ -99,7 +100,21 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
userIDs = ids
}
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
// ?kw= — 单搜索框:命中 提示词/模型/错误,或命中 用户(名称/邮箱/ID) 的记录都保留。
var items []model.EventLog
var total int64
var stats *repo.EventStats
var err error
if kw := strings.TrimSpace(c.Query("kw")); kw != "" {
kwIDs, uerr := h.admin.MatchUserIDs(c.Request.Context(), kw)
if uerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
items, total, stats, err = h.admin.LogsKeyword(c.Request.Context(), limit, offset, kind, status, since, kw, kwIDs, c.Query("source"))
} else {
items, total, stats, err = h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
+20 -15
View File
@@ -15,20 +15,21 @@ type EventRepository struct {
}
type EventListFilter struct {
Limit int
Offset int
Kind string
Status string // single status (status = ?)
Statuses []string // multiple statuses (status IN (?)) — used by the 画图台 grid
Since *time.Time
UserID string
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
Query string // free-text search over prompt / model / error (server-side, 跨页)
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
Limit int
Offset int
Kind string
Status string // single status (status = ?)
Statuses []string // multiple statuses (status IN (?)) — used by the 画图台 grid
Since *time.Time
UserID string
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
Query string // free-text search over prompt / model / error (server-side, 跨页)
KeywordUserIDs []string // with Query set: also match rows whose user_id is in this list (单搜索框: 用户 OR 内容)
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
}
type EventStats struct {
@@ -66,7 +67,11 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
}
if term := strings.TrimSpace(filter.Query); term != "" {
like := "%" + term + "%"
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
if len(filter.KeywordUserIDs) > 0 {
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ? OR user_id IN ?)", like, like, like, filter.KeywordUserIDs)
} else {
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
}
}
if filter.ExcludeSource != "" {
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
+29 -19
View File
@@ -89,11 +89,11 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
for _, item := range items {
_, bound := routable[item.ID]
out = append(out, map[string]any{
"id": item.ID,
"alias": item.Alias,
"type": item.Type,
"name": item.Name,
"provider": item.Provider,
"id": item.ID,
"alias": item.Alias,
"type": item.Type,
"name": item.Name,
"provider": item.Provider,
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
"sellable": bound && ModelFullyPriced(&item),
"ratios": repo.JSONStrings(item.Ratios),
@@ -122,25 +122,35 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
}
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
return s.logs(ctx, limit, offset, kind, status, statuses, since, userID, userIDs, nil, query, excludeSource, source, hasFile, excludeShowcase, mediaOnly)
}
// LogsKeyword 单搜索框:keyword 命中 提示词/模型/错误,或 user_id 在 kwUserIDs 内(名称/邮箱/ID 命中)即保留。
func (s *AdminReadService) LogsKeyword(ctx context.Context, limit, offset int, kind, status string, since *time.Time, keyword string, kwUserIDs []string, source string) ([]model.EventLog, int64, *repo.EventStats, error) {
return s.logs(ctx, limit, offset, kind, status, nil, since, "", nil, kwUserIDs, keyword, "", source, false, false, false)
}
func (s *AdminReadService) logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs, kwUserIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
var excludeFiles []string
if excludeShowcase {
excludeFiles = s.showcaseFileList(ctx)
}
items, total, err := s.events.List(ctx, repo.EventListFilter{
Limit: limit,
Offset: offset,
Kind: kind,
Status: status,
Statuses: statuses,
Since: since,
UserID: userID,
UserIDs: userIDs,
Query: query,
ExcludeSource: excludeSource,
Source: source,
HasFile: hasFile,
ExcludeFiles: excludeFiles,
MediaOnly: mediaOnly,
Limit: limit,
Offset: offset,
Kind: kind,
Status: status,
Statuses: statuses,
Since: since,
UserID: userID,
UserIDs: userIDs,
Query: query,
KeywordUserIDs: kwUserIDs,
ExcludeSource: excludeSource,
Source: source,
HasFile: hasFile,
ExcludeFiles: excludeFiles,
MediaOnly: mediaOnly,
})
if err != nil {
return nil, 0, nil, err