diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index a68b38c..7fba681 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -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 diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 816464c..acc7a06 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -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) diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index d4de6c5..2e9c081 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -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 diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index e417cdc..0d0f781 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -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:hover { color: var(--fg); background: var(--hover); } .home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); } -.home-bg .brand-spark { display: none; } .top-link.on { color: var(--m-orange); font-weight: 700; diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue index 2e485bd..dbea365 100644 --- a/frontend/src/views/LogsView.vue +++ b/frontend/src/views/LogsView.vue @@ -15,7 +15,6 @@ const kindFilter = ref('') // '' | 'image' | 'video' const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending' const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin' const search = ref('') -const userSearch = ref('') // 服务端用户搜索:名称 / 邮箱 / ID,跨页生效 const page = ref(1) const pageSize = ref(15) const total = ref(0) @@ -44,8 +43,7 @@ async function load() { if (kindFilter.value) qs.set('kind', kindFilter.value) if (statusFilter.value) qs.set('status', statusFilter.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('q', search.value.trim()) + if (search.value.trim()) qs.set('kw', search.value.trim()) const r = await api('/logs?' + qs.toString()) items.value = r.data?.data || [] total.value = Number(r.data?.total ?? items.value.length) @@ -186,13 +184,9 @@ const sourcePill = (s) => ({ -
- -
+ class="field !py-1.5 text-xs" placeholder="搜索 用户 / 模型 / 提示词 / 错误…" />
@@ -201,7 +195,7 @@ const sourcePill = (s) => ({
加载中…
- {{ (search.trim() || userSearch.trim()) ? '没有匹配的记录' : '还没有日志' }} + {{ search.trim() ? '没有匹配的记录' : '还没有日志' }}