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" "time"
"backend/internal/model" "backend/internal/model"
"backend/internal/repo"
"backend/internal/service" "backend/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -99,7 +100,21 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
userIDs = ids 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return return
+5
View File
@@ -24,6 +24,7 @@ type EventListFilter struct {
UserID string UserID string
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索) 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, 跨页) 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) 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" (测试模型) 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) HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
@@ -66,8 +67,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
} }
if term := strings.TrimSpace(filter.Query); term != "" { if term := strings.TrimSpace(filter.Query); term != "" {
like := "%" + term + "%" like := "%" + term + "%"
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) q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
} }
}
if filter.ExcludeSource != "" { if filter.ExcludeSource != "" {
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource) q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
} }
+10
View File
@@ -122,6 +122,15 @@ 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) { 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 var excludeFiles []string
if excludeShowcase { if excludeShowcase {
excludeFiles = s.showcaseFileList(ctx) excludeFiles = s.showcaseFileList(ctx)
@@ -136,6 +145,7 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st
UserID: userID, UserID: userID,
UserIDs: userIDs, UserIDs: userIDs,
Query: query, Query: query,
KeywordUserIDs: kwUserIDs,
ExcludeSource: excludeSource, ExcludeSource: excludeSource,
Source: source, Source: source,
HasFile: hasFile, HasFile: hasFile,
-1
View File
@@ -236,7 +236,6 @@ html.dark .nav-inner { box-shadow: 0 8px 30px -14px rgb(0 0 0 / 0.5); }
.top-link:active { transform: scale(0.95); } .top-link:active { transform: scale(0.95); }
.top-link:hover { color: var(--fg); background: var(--hover); } .top-link:hover { color: var(--fg); background: var(--hover); }
.home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); } .home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); }
.home-bg .brand-spark { display: none; }
.top-link.on { .top-link.on {
color: var(--m-orange); color: var(--m-orange);
font-weight: 700; font-weight: 700;
+3 -9
View File
@@ -15,7 +15,6 @@ const kindFilter = ref('') // '' | 'image' | 'video'
const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending' const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending'
const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin' const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin'
const search = ref('') const search = ref('')
const userSearch = ref('') // 服务端用户搜索:名称 / 邮箱 / ID,跨页生效
const page = ref(1) const page = ref(1)
const pageSize = ref(15) const pageSize = ref(15)
const total = ref(0) const total = ref(0)
@@ -44,8 +43,7 @@ async function load() {
if (kindFilter.value) qs.set('kind', kindFilter.value) if (kindFilter.value) qs.set('kind', kindFilter.value)
if (statusFilter.value) qs.set('status', statusFilter.value) if (statusFilter.value) qs.set('status', statusFilter.value)
if (sourceFilter.value) qs.set('source', sourceFilter.value) if (sourceFilter.value) qs.set('source', sourceFilter.value)
if (userSearch.value.trim()) qs.set('user', userSearch.value.trim()) if (search.value.trim()) qs.set('kw', search.value.trim())
if (search.value.trim()) qs.set('q', search.value.trim())
const r = await api('/logs?' + qs.toString()) const r = await api('/logs?' + qs.toString())
items.value = r.data?.data || [] items.value = r.data?.data || []
total.value = Number(r.data?.total ?? items.value.length) total.value = Number(r.data?.total ?? items.value.length)
@@ -186,13 +184,9 @@ const sourcePill = (s) => ({
<SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" /> <SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" /> <SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" /> <SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="w-44">
<input v-model="userSearch" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索用户 名称/邮箱/ID…" />
</div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch" <input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" /> class="field !py-1.5 text-xs" placeholder="搜索 用户 / 模型 / 提示词 / 错误…" />
</div> </div>
</div> </div>
@@ -201,7 +195,7 @@ const sourcePill = (s) => ({
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div> <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span> <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span>
<span class="text-sm">{{ (search.trim() || userSearch.trim()) ? '没有匹配的记录' : '还没有日志' }}</span> <span class="text-sm">{{ search.trim() ? '没有匹配的记录' : '还没有日志' }}</span>
</div> </div>
<!-- Each row is a thumbnail + a stack of model/prompt + a meta line. <!-- Each row is a thumbnail + a stack of model/prompt + a meta line.