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) => ({