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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user